From 057cf871d0d3b399824dc7868cd6efe421a4c1a3 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Mon, 9 Feb 2026 12:47:58 +0100 Subject: [PATCH] Examples Screen Add compose ui test to the deps Add PDE window utilities for Compose and Swing Introduces PDESwingWindow and PDEComposeWindow classes to simplify creating themed and localized windows in Compose and Swing applications. Includes macOS-specific handling for full window content and localization support for window titles. Refactor beta welcome window handling Replaces custom JFrame setup in WelcomeToBeta with PDESwingWindow and PDEComposeWindow, centralizing window logic and close handling. Adds onClose callback to PDESwingWindow for improved lifecycle management. Also ensures beta welcome preference is reset on forced update check. Theming (#1298) * Add Material3-based Processing theme and typography Introduces Colors.kt with custom color schemes for light and dark themes using Material3. Refactors Theme.kt to use Material3 theming, adds a PDETheme composable, and provides a desktop preview app for theme components. Updates Typography.kt to use Space Grotesk font family and defines new typography styles for Material3. * Refactor to use Material3 and update theme usage Replaces Material2 components with Material3 in WelcomeToBeta, removes custom PDEButton in favor of Material3 Button, and updates theme usage to PDETheme. Also simplifies background modifier in PDETheme and removes unused Kotlin Multiplatform plugin from build.gradle.kts. * Add Space Grotesk font files and license Includes SpaceGrotesk font variants (Bold, Light, Medium, Regular, SemiBold) and the associated SIL Open Font License. This enables usage of the Space Grotesk typeface in the project. * Update markdown renderer to m3 and adjust UI Switched markdown renderer imports from m2 to m3 and updated the dependency version to 0.37.0. Adjusted WelcomeToBeta window size, layout, and logo dimensions for improved appearance. Ensured Box in Theme.kt fills available space for better layout consistency. Switch from ProcessingTheme to PDETheme in window UI Replaces the use of ProcessingTheme with PDETheme in the PDEWindowContent composable Refactor preferences to Jetpack Compose UI Replaces the legacy PreferencesFrame with a new Jetpack Compose-based preferences UI. Adds reactive preferences management using a custom ReactiveProperties class, and introduces modular preference groups (General, Interface, Other) with composable controls. Updates Base.java to launch the new preferences window, and refactors theme and window code for Compose integration. Remove obsolete TODO for onClose callback Refactor theme system to Material 3 color schemes Replaces legacy color definitions with Material 3 color schemes and introduces extended color support for warnings. Dialogs in Messages.kt are now implemented using Compose Material 3 components for a modern UI. Removes deprecated color sets and updates PDETheme to use new color schemes, improving consistency and maintainability. Add PDEWelcome Composable UI screen Introduces a new PDEWelcome.kt file with a Composable UI for the Processing welcome screen. Includes layout with buttons for language selection, new sketch, examples, and sketchbook, as well as a placeholder for right-side content and a main entry point for launching the window. Enhance Preferences reactivity and test coverage Refactored ReactiveProperties to use snapshotStateMap for Compose reactivity. Improved PreferencesProvider and watchFile composables with better file watching, override support via system properties, and added documentation. Updated PreferencesKtTest to use temporary files and verify file-to-UI reactivity. Add compose ui test to the deps Initial layout Revamp welcome screen UI and add social icons Refactors the PDEWelcome screen to improve layout, update button icons, and add support for Discord, GitHub, and Instagram SVG icons. The welcome screen now receives a Base instance for proper action handling, and new methods replace deprecated ones in Base.java. Updates related menu actions to pass the Base instance as needed. Add example previews to welcome screen Replaces placeholder text on the right side of the PDEWelcome screen with a LazyColumn displaying example sketches. Each example attempts to show a preview image if available, or a placeholder icon otherwise. Introduces an Example data class and related image loading logic. Add hover-activated play button to example previews Introduced a hover effect on example preview images in the welcome screen, displaying a play button that opens the example when clicked. Refactored title key usage for consistency. Localize welcome screen UI strings Replaced hardcoded strings in the PDEWelcome screen with localized values using the LocalLocale context. Added new keys for the welcome screen to the English and Dutch language property files to support internationalization. Add language selector and UI improvements to welcome screen Introduces a language selection dropdown to the PDE welcome screen using a shared composable from preferences. Refactors the layout for better spacing, updates example cards with animated overlays, and replaces the show-on-startup button with a checkbox. Also adds a new translation key for the open example button. Refactor example listing and randomize welcome sketches Moved example folder listing logic in Contributions.ExamplesList to a companion object function for reuse. Updated PDEWelcome to display a randomized selection of sketches from all available examples, replacing the previous static list. Refactor example handling to use Sketch objects Replaces Example objects with Sketch objects for managing example sketches in the welcome screen. Updates all relevant usages to reference Sketch properties, simplifying the code and improving clarity. Add vertical scrollbar to welcome screen examples Introduces a VerticalScrollbar to the examples list in the PDEWelcome screen for improved navigation. Also adjusts spacing and arrangement in several UI components for better layout consistency, and updates the welcome screen title in the language properties. Add rounded corners to buttons in PDEWelcome Introduced a RoundedCornerShape with 12.dp radius and applied it to various buttons in the PDEWelcome screen for improved UI consistency and aesthetics. Refactor PDEWelcome UI and add Sketch card composable Refactored the PDEWelcome screen for improved structure and readability, including extracting the example preview into a reusable Sketch.card composable. Updated icon usage for RTL support, adjusted layout and padding, and improved the examples list initialization. Also, customized scrollbar style in PDETheme for a more consistent UI appearance. Add unique window handling to prevent duplicates Introduces a 'unique' parameter to PDESwingWindow and PDEComposeWindow, allowing windows to be identified by a KClass and preventing multiple instances of the same window. If a window with the same unique identifier exists, it is brought to the front and the new one is disposed. This helps avoid duplicate welcome or other singleton windows. Refactor dialog handling and improve AlertDialog UI Refactored the showDialog function to accept a modifier and updated all AlertDialog usages to use RectangleShape and the modifier parameter. Improved dialog sizing and positioning by dynamically adjusting the window size based on content, and set additional window properties for better integration on macOS. Set application window icon using Toolkit.setIcon Added calls to Toolkit.setIcon(window) in Start.kt and Window.kt to ensure the application window icon is set consistent Simplify imports and update scrollbar colors in Theme.kt Consolidated import statements for Compose libraries using wildcard imports to reduce verbosity. Updated scrollbar hover and unhover colors to use the default outlineVariant color without alpha modification. Preferences screen Adds most of the options for the preferences screen based on the new design Replace Row with Column in sketch naming options Changed the layout container from Row to Column for the sketch naming options in the General preferences UI. This improves vertical arrangement and removes unnecessary padding modifiers. Enhance preferences UI and add memory options Refactored preferences UI to swap primary and tertiary colors, improved sidebar button color handling, and updated search bar logic. Added clickable folder icon for sketchbook location selection. Improved interface scale slider logic and display. Added new preferences for increasing available memory and max memory, with enable/disable logic. Updated experimental preferences to use localized description keys if available. Extended ShimAWT to support folder selection via callback and refactored file/folder selection logic for better composability. Updated language properties with new preference keys and descriptions. Fixed a color issue Improve preferences UI layout and window size Increased the preferences window width from 800 to 850 pixels for better layout. Updated the General preferences to display FilterChip options in rows with spacing, improving visual organization and usability. Add theme selection and UI improvements to preferences Introduces a theme selector for the editor in the Interface preferences, allowing users to choose between system, dark, and light themes. Updates Coding and General preferences with improved layout and feedback, including a copied state for diagnostics. Updates localization strings to support new features and labels. Added the ability to undo the changes + icon/language changes Update animation spec for slideInVertically Changed the animationSpec for slideInVertically from a 500ms EaseOutBounce to a 300ms default tween for consistency and smoother transitions. initial scanning for sketches Refactor examples UI and add header component Refactored the examples UI to support search, sorting, and category navigation. Introduced a reusable Header composable for consistent page headers and search bars, and updated preferences and examples screens to use it. Enhanced Mode API to support dynamic example discovery and folder watching. Scrolling rework Refactor examples UI and add exampleCard composable Refactors the PDEExamples UI to improve sidebar navigation, update layout spacing, and replace sketch.card with a new exampleCard composable for displaying sketch previews. Adds sticky headers for groups, simplifies category button styles, and introduces image preview logic for sketches with fallback to a logo icon. New UX Testing research --- .../main/resources/languages/PDE.properties | 2 + app/src/processing/app/Mode.java | 36 +- app/src/processing/app/Sketch.java | 15 +- app/src/processing/app/api/Mode.kt | 171 ++++++ app/src/processing/app/ui/ExamplesFrame.java | 51 +- app/src/processing/app/ui/PDEExamples.kt | 496 ++++++++++++++++++ app/src/processing/app/ui/PDEHeader.kt | 92 ++++ app/src/processing/app/ui/PDEPreferences.kt | 58 +- .../app/ui/components/PDESketchCard.kt | 86 +++ 9 files changed, 893 insertions(+), 114 deletions(-) create mode 100644 app/src/processing/app/api/Mode.kt create mode 100644 app/src/processing/app/ui/PDEExamples.kt create mode 100644 app/src/processing/app/ui/PDEHeader.kt create mode 100644 app/src/processing/app/ui/components/PDESketchCard.kt diff --git a/app/src/main/resources/languages/PDE.properties b/app/src/main/resources/languages/PDE.properties index c9189efa3a..ba05656441 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 28e5267e82..aec0b35cd9 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 8bb50352b0..76c4becca8 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 0000000000..5db2d990bd --- /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 0d8e89be9a..433ede6336 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 0000000000..0e9f3de844 --- /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 0000000000..0fe78a6a47 --- /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 8d1e749c9f..d395bb980e 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 0000000000..ca05eadd27 --- /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