diff --git a/app/src/main/resources/languages/PDE.properties b/app/src/main/resources/languages/PDE.properties index c9189efa3..ba0565644 100644 --- a/app/src/main/resources/languages/PDE.properties +++ b/app/src/main/resources/languages/PDE.properties @@ -299,7 +299,9 @@ sketchbook = Sketchbook sketchbook.tree = Sketchbook # Examples (Frame) +examples.frame = Examples examples.title = %s Examples +examples.description = Browse the examples included with Processing, as well as contributed examples from libraries you have installed. examples.add_examples = Add Examples... examples.libraries = Contributed Libraries examples.core_libraries = Libraries diff --git a/app/src/processing/app/Mode.java b/app/src/processing/app/Mode.java index 28e5267e8..aec0b35cd 100644 --- a/app/src/processing/app/Mode.java +++ b/app/src/processing/app/Mode.java @@ -23,25 +23,21 @@ package processing.app; -import java.awt.*; -import java.awt.event.*; -import java.io.*; -import java.util.*; -import java.util.List; - -import javax.swing.*; - import processing.app.contrib.ContributionManager; -import processing.app.syntax.*; -import processing.app.ui.Editor; -import processing.app.ui.EditorException; -import processing.app.ui.EditorState; -import processing.app.ui.ExamplesFrame; -import processing.app.ui.Recent; +import processing.app.syntax.PdeTokenMarker; +import processing.app.syntax.TokenMarker; +import processing.app.ui.*; import processing.app.ui.Toolkit; import processing.core.PApplet; import processing.utils.SketchException; +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionListener; +import java.io.*; +import java.util.*; +import java.util.List; + public abstract class Mode { protected Base base; @@ -87,9 +83,12 @@ public abstract class Mode { */ protected ClassLoader classLoader; + public Mode(Base base, File folder) { + this(folder); + this.base = base; + } - public Mode(Base base, File folder) { - this.base = base; + public Mode(File folder) { this.folder = folder; tokenMarker = createTokenMarker(); @@ -605,10 +604,7 @@ public void rebuildExamplesFrame() { public void showExamplesFrame() { - if (examplesFrame == null) { - examplesFrame = new ExamplesFrame(base, this); - } - examplesFrame.setVisible(); + PDEExamplesKt.show(this, base); } diff --git a/app/src/processing/app/Sketch.java b/app/src/processing/app/Sketch.java index 8bb50352b..76c4becca 100644 --- a/app/src/processing/app/Sketch.java +++ b/app/src/processing/app/Sketch.java @@ -27,13 +27,11 @@ import processing.app.ui.Editor; import processing.app.ui.Recent; import processing.app.ui.Toolkit; -import processing.core.*; +import processing.core.PApplet; -import java.awt.Color; -import java.awt.Component; -import java.awt.Container; -import java.awt.EventQueue; -import java.awt.FileDialog; +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; import java.awt.event.ActionListener; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; @@ -42,9 +40,6 @@ import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; -import javax.swing.*; -import javax.swing.border.EmptyBorder; - /** * Stores information about files in the current sketch. @@ -1314,7 +1309,7 @@ static protected Settings loadProperties(File folder) throws IOException { * Because the default mode will be the first in the list, this will always * prefer that one over the others. */ - static protected File findMain(File folder, List modeList) { + static public File findMain(File folder, List modeList) { try { Settings props = Sketch.loadProperties(folder); String main = props.get("main"); diff --git a/app/src/processing/app/api/Mode.kt b/app/src/processing/app/api/Mode.kt new file mode 100644 index 000000000..5db2d990b --- /dev/null +++ b/app/src/processing/app/api/Mode.kt @@ -0,0 +1,171 @@ +package processing.app.api + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.toMutableStateList +import kotlinx.coroutines.* +import processing.app.Mode +import processing.app.contrib.ContributionType +import java.io.File +import java.nio.file.* +import kotlin.io.path.isDirectory +import kotlin.io.path.name + +class Mode { + companion object { + /** + * Find sketches in the given root folder for the specified mode. + * + * Based on ExamplesFrame.buildTree() + * + * @param root The root directory to search for sketches. + * @param mode The mode to filter sketches by. + * @return A list of sketch folders found in the root directory. + */ + fun findExampleSketches( + mode: Mode, + sketchbookFolder: File? = null, + scope: CoroutineScope? = null + ): List { + val baseExamples = mode.exampleCategoryFolders.mapNotNull { + searchForSketches(it.toPath(), mode, { true }, scope) + } + + val coreLibraryExamples = mode.coreLibraries.mapNotNull { + searchForSketches(it.examplesFolder.toPath(), mode, { true }, scope).replace( + name = it.name, + path = it.path + ) + }.wrap( + name = "Libraries", + path = File(mode.coreLibraries.first().path).parent.toString() + ) + + val contributedLibraryExamples = mode.contribLibraries.mapNotNull { + searchForSketches(it.examplesFolder.toPath(), mode, { true }, scope).replace( + name = it.name, + path = it.path + ) + }.wrap( + name = "Contributed Libraries", + path = File(mode.contribLibraries.firstOrNull()?.path ?: mode.getContentFile("").path).parent.toString() + ) + + val contributedExamplePacks = sketchbookFolder?.let { root -> + ContributionType.EXAMPLES.listCandidates(root).mapNotNull { + searchForSketches(it.toPath(), mode, { true }, scope) + } + } + return (baseExamples + coreLibraryExamples + contributedLibraryExamples + (contributedExamplePacks + ?: emptyList())) + } + + /** + * Find sketches in the given root folder for the specified mode. + * + * Based on Base.addSketches() + * + * @param root The root directory to search for sketches. + * @param mode The mode to filter sketches by. + * @return A list of sketch folders found in the root directory. + */ + fun searchForSketches( + root: Path, + mode: Mode, + filter: ((Path) -> Boolean) = { true }, + scope: CoroutineScope? = null + ): Sketch.Companion.Folder? { + if (!root.isDirectory()) return null + if (!filter(root)) return null + + val stream = Files.newDirectoryStream(root) + val (sketchFolders, subfolders) = stream + .filter { path -> path.isDirectory() } + .filter { path -> filter(path) } + .partition { path -> + val main = processing.app.Sketch.findMain(path.toFile(), listOf(mode)) + main != null + } + val sketches = sketchFolders.map { + Sketch.Companion.Sketch( + name = it.fileName.toString(), + path = it.toString(), + mode = mode.identifier + ) + }.toMutableStateList() + val children = subfolders.mapNotNull { + searchForSketches(it, mode, filter) + }.toMutableStateList() + if (sketches.isEmpty() && children.isEmpty()) return null + + scope?.launch(Dispatchers.IO) { + val watchService: WatchService = FileSystems + .getDefault() + .newWatchService() + + val watcher = root.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY) + while (isActive) { + delay(100) + watchService.poll() ?: continue + + watcher.pollEvents().forEach { _ -> + val updatedFolder = searchForSketches(root, mode, filter) ?: return@forEach + + sketches.clear() + sketches.addAll(updatedFolder.sketches) + children.clear() + children.addAll(updatedFolder.children) + } + + } + } + + return Sketch.Companion.Folder( + name = root.name, + path = root.toString(), + sketches = sketches, + children = children + ) + } + + fun Sketch.Companion.Folder?.wrap( + name: String? = this?.name, + path: String? = this?.path + ): Sketch.Companion.Folder? { + if (this == null) return null; + return Sketch.Companion.Folder( + name = name ?: this.name, + path = path ?: this.path, + sketches = mutableStateListOf(), + children = mutableStateListOf(this) + ) + } + + fun List.wrap( + name: String, + path: String, + ): List { + if (this.isEmpty()) return emptyList() + return listOf( + Sketch.Companion.Folder( + name = name, + path = path, + sketches = mutableStateListOf(), + children = this.toMutableStateList() + ) + ) + } + + fun Sketch.Companion.Folder?.replace( + name: String? = this?.name, + path: String? = this?.path + ): Sketch.Companion.Folder? { + if (this == null) return null; + return Sketch.Companion.Folder( + name = name ?: this.name, + path = path ?: this.path, + sketches = this.sketches, + children = this.children + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/ExamplesFrame.java b/app/src/processing/app/ui/ExamplesFrame.java index 0d8e89be9..433ede633 100644 --- a/app/src/processing/app/ui/ExamplesFrame.java +++ b/app/src/processing/app/ui/ExamplesFrame.java @@ -22,11 +22,20 @@ package processing.app.ui; -import java.awt.BorderLayout; -import java.awt.Color; -import java.awt.Component; -import java.awt.Container; -import java.awt.Point; +import processing.app.*; +import processing.app.contrib.Contribution; +import processing.app.contrib.ContributionManager; +import processing.app.contrib.ContributionType; +import processing.app.contrib.ExamplesContribution; +import processing.core.PApplet; +import processing.data.StringDict; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.event.TreeExpansionEvent; +import javax.swing.event.TreeExpansionListener; +import javax.swing.tree.*; +import java.awt.*; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; @@ -37,36 +46,6 @@ import java.util.Comparator; import java.util.Enumeration; -import javax.swing.Box; -import javax.swing.JButton; -import javax.swing.JFrame; -import javax.swing.JPanel; -import javax.swing.JScrollPane; -import javax.swing.JTree; -import javax.swing.border.EmptyBorder; -import javax.swing.event.TreeExpansionEvent; -import javax.swing.event.TreeExpansionListener; -import javax.swing.tree.DefaultMutableTreeNode; -import javax.swing.tree.TreeModel; -import javax.swing.tree.TreeNode; -import javax.swing.tree.TreePath; -import javax.swing.tree.TreeSelectionModel; - -import processing.app.Base; -import processing.app.Language; -import processing.app.Library; -import processing.app.Mode; -import processing.app.Platform; -import processing.app.Preferences; -import processing.app.SketchReference; -import processing.app.contrib.Contribution; -import processing.app.contrib.ContributionManager; -import processing.app.contrib.ContributionType; -import processing.app.contrib.ExamplesContribution; - -import processing.core.PApplet; -import processing.data.StringDict; - public class ExamplesFrame extends JFrame { protected Base base; @@ -283,7 +262,7 @@ void expandTree(JTree tree, Object object, String[] items, } } - + @Deprecated protected DefaultMutableTreeNode buildTree() { DefaultMutableTreeNode root = new DefaultMutableTreeNode(); //"Examples"); diff --git a/app/src/processing/app/ui/PDEExamples.kt b/app/src/processing/app/ui/PDEExamples.kt new file mode 100644 index 000000000..0e9f3de84 --- /dev/null +++ b/app/src/processing/app/ui/PDEExamples.kt @@ -0,0 +1,496 @@ +package processing.app.ui + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.application +import kotlinx.coroutines.launch +import processing.app.Base +import processing.app.Mode +import processing.app.Platform +import processing.app.api.Sketch +import processing.app.ui.PDEExamples.Companion.examples +import processing.app.ui.components.exampleCard +import processing.app.ui.theme.LocalLocale +import processing.app.ui.theme.PDEComposeWindow +import processing.app.ui.theme.PDESwingWindow +import processing.app.ui.theme.PDETheme +import java.awt.Dimension +import java.io.File +import javax.swing.SwingUtilities + + +class PDEExamples { + companion object { + @OptIn(ExperimentalComposeUiApi::class) + @Composable + fun examples(mode: Mode, base: Base? = null) { + /** + * Swapping primary and tertiary colors for the preferences window, probably should do that program-wide + */ + val originalScheme = MaterialTheme.colorScheme + MaterialTheme( + colorScheme = originalScheme.copy( + primary = originalScheme.tertiary, + onPrimary = originalScheme.onTertiary, + primaryContainer = originalScheme.tertiaryContainer, + onPrimaryContainer = originalScheme.onTertiaryContainer, + + tertiary = originalScheme.primary, + onTertiary = originalScheme.onPrimary, + tertiaryContainer = originalScheme.primaryContainer, + onTertiaryContainer = originalScheme.onPrimaryContainer, + ) + ) { + val sketches = remember { mutableStateListOf() } + + var searchQuery by remember { mutableStateOf("") } + + val scope = rememberCoroutineScope() + + LaunchedEffect(mode) { + val foundSketches = + processing.app.api.Mode.findExampleSketches( + mode = mode, + sketchbookFolder = null, + scope = scope + ) + sketches.clear() + sketches.addAll(foundSketches) + } + + val querriedSketches by derivedStateOf { + if (searchQuery.isBlank()) { + sketches + } else { + fun filterFolder(folder: Sketch.Companion.Folder): Sketch.Companion.Folder? { + val filteredSketches = folder.sketches.filter { sketch -> + sketch.name.contains(searchQuery, ignoreCase = true) || + sketch.path.contains(searchQuery, ignoreCase = true) + } + val filteredChildren = folder.children.mapNotNull { child -> + filterFolder(child) + } + return if (filteredSketches.isNotEmpty() || filteredChildren.isNotEmpty()) { + Sketch.Companion.Folder( + name = folder.name, + path = folder.path, + sketches = filteredSketches, + children = filteredChildren + ) + } else { + null + } + } + + sketches.mapNotNull { folder -> + filterFolder(folder) + } + } + } + + val alphabeticalSketches by derivedStateOf { + fun sortFolder(folder: Sketch.Companion.Folder): Sketch.Companion.Folder { + val sortedSketches = folder.sketches.sortedBy { it.name } + val sortedChildren = folder.children.map { child -> + sortFolder(child) + }.sortedBy { it.name } + return Sketch.Companion.Folder( + name = folder.name, + path = folder.path, + sketches = sortedSketches, + children = sortedChildren + ) + } + querriedSketches.map { folder -> + sortFolder(folder) + } + } + + + /** + * Flatting the structure into a flat list as scrolling to an item can only be done with an index + */ + data class SketchItem( + val type: String, + val item: Any + ) + + val flatSketches by derivedStateOf { + alphabeticalSketches.flatMap { group -> + val children = group.children.flatMap { category -> + fun pairs(category: Sketch.Companion.Folder): List = listOf( + SketchItem( + "category", + category + ) + ) + category.sketches.map { sketch -> + SketchItem( + "sketch", + sketch + ) + } + category.children.flatMap { pairs(it) } + + pairs(category) + } + val itself = SketchItem( + "group", + group + ) + listOf(itself) + group.sketches.map { sketch -> + SketchItem( + "sketch", + sketch + ) + } + children + } + } + + val finalSketches = flatSketches + val locale = LocalLocale.current + + Column { + Header( + searchable = SearchState( + query = searchQuery, + onQueryChange = { searchQuery = it } + ), + headlineKey = "", + headline = { + Text(locale["examples.title"].replace("%s", mode.getTitle())) + }, + descriptionKey = "examples.description" + ) { + Button( + onClick = { + querriedSketches + .flatMap { group -> + group.children.flatMap { category -> + fun allSketches(category: Sketch.Companion.Folder): List { + return category.sketches + category.children.flatMap { + allSketches(it) + } + } + allSketches(category) + } + } + .shuffled() + .firstOrNull()?.let { sketch -> + base?.handleOpen("${sketch.path}/${sketch.name}.${mode.defaultExtension}") + } + }, + modifier = Modifier.align(Alignment.CenterVertically) + ) { + Icon(Icons.Default.Shuffle, contentDescription = null) + Text("Random") + } + } + HorizontalDivider() + Row( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + .fillMaxHeight() + ) { + /** + * Left navigation column with categories + */ + var hovered by remember { mutableStateOf(false) } + val previewState = rememberLazyGridState() + val scope = rememberCoroutineScope() + val current by derivedStateOf { + val visibleItems = previewState.layoutInfo.visibleItemsInfo + // Grab the first visible category or sketch to determine the current location + visibleItems.firstNotNullOfOrNull { + val item = finalSketches.getOrNull(it.index) ?: return@firstNotNullOfOrNull null + if (item.type != "category") return@firstNotNullOfOrNull null + val category = item.item as Sketch.Companion.Folder + category.path + } ?: visibleItems.slice(visibleItems.size / 2.. + val isOpen = current?.startsWith(group.path) == true + OutlinedButton( + onClick = { + scope.launch { + val index = finalSketches.indexOfFirst { + it.type == "group" && (it.item as Sketch.Companion.Folder).path == group.path + } + if (index >= 0) { + previewState.animateScrollToItem(index) + } + } + }, + colors = if (isOpen) ButtonDefaults.buttonColors() else ButtonDefaults.outlinedButtonColors(), + border = ButtonDefaults.outlinedButtonBorder(!isOpen), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(start = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = group.name, + modifier = Modifier + ) + } + } + + val state = rememberLazyGridState() + LaunchedEffect(current) { + if (!isOpen) return@LaunchedEffect + val index = group.children.indexOfFirst { category -> + current?.startsWith(category.path) == true + } + val visible = state.layoutInfo.visibleItemsInfo + if (visible.slice(3..visible.size - 3) + .any { it.index == index } + ) return@LaunchedEffect + if (index >= 0) { + state.animateScrollToItem(index) + } + } + val modifier = if (isOpen) { + Modifier.weight(1f, false) + } else { + Modifier.height(0.dp) + } + Box( + modifier = modifier + .animateContentSize() + ) { + val alpha by animateFloatAsState( + targetValue = if (hovered) 1f else 0f + ) + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .alpha(alpha) + .fillMaxHeight(), + adapter = rememberScrollbarAdapter( + scrollState = state, + ) + ) + LazyVerticalGrid( + modifier = Modifier + .fillMaxWidth() + .padding(start = 6.dp, end = 12.dp), + columns = GridCells.Fixed(1), + state = state, + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + group.children.map { category -> + val isCurrent = current?.startsWith(category.path) == true + item { + TextButton( + onClick = { + scope.launch { + val index = finalSketches.indexOfFirst { + it.type == "category" && (it.item as Sketch.Companion.Folder).path == category.path + } + if (index >= 0) { + previewState.animateScrollToItem(index) + } + } + }, + colors = if (isCurrent) ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ) else ButtonDefaults.outlinedButtonColors(), + shape = RoundedCornerShape(6.dp), + contentPadding = PaddingValues(horizontal = 12.dp), + modifier = Modifier + .height(36.dp) + ) { + Text( + text = category.name, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } + } + } + } + /** + * Right content column with examples + */ + Box( + modifier = Modifier + .weight(1f) + ) { + LazyVerticalGrid( + modifier = Modifier + .fillMaxSize() + .padding(end = 24.dp), + columns = GridCells.Adaptive(minSize = 240.dp), + contentPadding = PaddingValues(vertical = 24.dp), + state = previewState, + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + finalSketches.map { item -> + when (item.type) { + "group" -> { + val group = item.item as Sketch.Companion.Folder + item( + key = "group-${group.path}", + span = { GridItemSpan(maxLineSpan) } + ) { + Text( + text = group.name, + style = MaterialTheme.typography.headlineSmall, + ) + } + } + + "category" -> { + val category = item.item as Sketch.Companion.Folder + item( + key = "category-${category.path}", + span = { GridItemSpan(maxLineSpan) } + ) { + Text( + text = category.name, + style = MaterialTheme.typography.titleMedium, + ) + } + } + + "sketch" -> { + val sketch = item.item as Sketch.Companion.Sketch + item( + key = "sketch-${sketch.path}" + ) { + Box( + modifier = Modifier + .animateItem(), + ) { + sketch.exampleCard(onOpen = { + base?.handleOpen("${sketch.path}/${sketch.name}.${mode.defaultExtension}") + }) + } + } + } + + else -> { + item { + Box { + + } + } + } + } + } + } + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(), + adapter = rememberScrollbarAdapter( + scrollState = previewState, + ) + ) + } + } + } + } + } + } +} + + +fun show(mode: Mode, base: Base?) { + SwingUtilities.invokeLater { + PDESwingWindow( + unique = mode::class, + titleKey = "examples.frame", + fullWindowContent = true, + size = Dimension(1100, 700), + minSize = Dimension(700, 500), + ) { + examples(mode, base) + } + } +} + + +/** + * Make sure you run Processing with + * ``` + * ./gradlew run + * ``` + * at least once so that the java folder exists + * + * or + * + * use the Processing run configuration in IDEA + */ +fun main() { + application { + // TODO: Migrate to using the actual Java mode from the application + val folder = File("app/build/resources-bundled/common/modes/java") + if (!folder.exists()) { + error("The java mode folder does not exist: ${folder.absolutePath}\nMake sure to run Processing at least once using './gradlew run' or the Processing run configuration in IDEA") + } + val javaMode = object : Mode(folder) { + override fun getIdentifier() = "java" + override fun getTitle() = "Java" + override fun createEditor(base: Base?, path: String?, state: EditorState?) = TODO("Not yet implemented") + override fun getDefaultExtension() = "pde" + override fun getExtensions() = arrayOf("pde", "java") + override fun getIgnorable() = Platform.getSupportedVariants().keyArray() + } + PDEComposeWindow( + titleKey = "pde.examples.title", + size = DpSize(1100.dp, 700.dp), + fullWindowContent = true + ) { + PDETheme(darkTheme = false) { + examples(javaMode) + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/PDEHeader.kt b/app/src/processing/app/ui/PDEHeader.kt new file mode 100644 index 000000000..0fe78a6a4 --- /dev/null +++ b/app/src/processing/app/ui/PDEHeader.kt @@ -0,0 +1,92 @@ +package processing.app.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import processing.app.ui.theme.LocalLocale + +data class SearchState(val query: String, val onQueryChange: (String) -> Unit) + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun Header( + searchable: SearchState? = null, + headlineKey: String, + headline: @Composable () -> Unit = { + val locale = LocalLocale.current + Text(locale[headlineKey]) + }, + descriptionKey: String, + description: @Composable () -> Unit = { + val locale = LocalLocale.current + Text(locale[descriptionKey]) + }, + searchKey: String = "search", + searchPlaceholder: @Composable () -> Unit = { + val locale = LocalLocale.current + Text(locale[searchKey]) + }, + extraContent: @Composable RowScope.() -> Unit = {} +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 36.dp, top = 48.dp, end = 24.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Column( + modifier = Modifier + .weight(1f) + ) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Medium)) { + headline() + } + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { + description() + } + } + extraContent() + Spacer(modifier = Modifier.width(96.dp)) + searchable?.apply { + SearchBar( + modifier = Modifier + .widthIn(max = 250.dp), + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = { + + }, + trailingIcon = { + if (query.isEmpty()) { + Icon(Icons.Default.Search, contentDescription = null) + } else { + IconButton( + onClick = { onQueryChange("") } + ) { + Icon(Icons.Default.Close, contentDescription = null) + } + } + }, + expanded = false, + onExpandedChange = { }, + placeholder = { searchPlaceholder() } + ) + }, + expanded = false, + onExpandedChange = {}, + content = {} + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/PDEPreferences.kt b/app/src/processing/app/ui/PDEPreferences.kt index 8d1e749c9..d395bb980 100644 --- a/app/src/processing/app/ui/PDEPreferences.kt +++ b/app/src/processing/app/ui/PDEPreferences.kt @@ -10,8 +10,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.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.Search import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.SnapshotStateList @@ -36,9 +34,10 @@ import processing.app.ui.theme.* import java.awt.Dimension import java.awt.event.WindowEvent import java.awt.event.WindowListener -import java.util.* +import java.util.Properties import javax.swing.SwingUtilities import javax.swing.WindowConstants +import javax.xml.catalog.CatalogFeatures.defaults fun show() { @@ -186,51 +185,14 @@ class PDEPreferences { /** * Header */ - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 36.dp, top = 48.dp, end = 24.dp, bottom = 24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom - ) { - Column( - modifier = Modifier - .weight(1f) - ) { - Text( - text = locale["preferences"], - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Medium), - ) - Text( - text = locale["preferences.description"], - style = MaterialTheme.typography.bodySmall, - ) - } - Spacer(modifier = Modifier.width(96.dp)) - SearchBar( - modifier = Modifier - .widthIn(max = 250.dp), - inputField = { - SearchBarDefaults.InputField( - query = preferencesQuery, - onQueryChange = { - preferencesQuery = it - }, - onSearch = { - - }, - trailingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - expanded = false, - onExpandedChange = { }, - placeholder = { Text("Search") } - ) - }, - expanded = false, - onExpandedChange = {}, - ) { - - } - } + Header( + searchable = SearchState( + query = preferencesQuery, + onQueryChange = { preferencesQuery = it } + ), + headlineKey = "preferences", + descriptionKey = "preferences.description" + ) HorizontalDivider() Box { Row( diff --git a/app/src/processing/app/ui/components/PDESketchCard.kt b/app/src/processing/app/ui/components/PDESketchCard.kt new file mode 100644 index 000000000..ca05eadd2 --- /dev/null +++ b/app/src/processing/app/ui/components/PDESketchCard.kt @@ -0,0 +1,86 @@ +package processing.app.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.decodeToImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import processing.app.api.Sketch +import processing.app.ui.theme.LocalLocale +import java.io.File +import kotlin.io.inputStream + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +fun Sketch.Companion.Sketch.exampleCard(onOpen: () -> Unit = {}) { + val locale = LocalLocale.current + Column( + modifier = Modifier.Companion + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onOpen) + .padding(12.dp) + ) { + Box( + Modifier.Companion + .border( + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium + ) + .background( + MaterialTheme.colorScheme.surfaceContainerLowest, + shape = MaterialTheme.shapes.medium + ) + .clip(MaterialTheme.shapes.medium) + .fillMaxSize() + .aspectRatio(16 / 9f) + ) { + val image = remember { + File(path, "${name}.png").takeIf { it.exists() } + } + if (image == null) { + Icon( + painter = painterResource("logo.svg"), + modifier = Modifier.Companion + .size(75.dp) + .align(Alignment.Companion.Center), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + contentDescription = "Processing Logo" + ) + HorizontalDivider() + } else { + val imageBitmap: ImageBitmap = remember(image) { + image.inputStream().readAllBytes().decodeToImageBitmap() + } + Image( + painter = BitmapPainter(imageBitmap), + modifier = Modifier.Companion + .fillMaxSize(), + contentDescription = name + ) + } + + } + Text(name) + } +} \ No newline at end of file