|
| 1 | +--- |
| 2 | +description: Rules for using Flowery.NET controls |
| 3 | +alwaysApply: false |
| 4 | +--- |
| 5 | +# Flowery.NET |
| 6 | + |
| 7 | +## General Rules |
| 8 | + |
| 9 | +- IMPORTANT: When editing files, make small, targeted edits with at least 5 lines of unique context before and after the change point. Avoid large multi-line replacements; prefer multiple smaller edits. |
| 10 | +- Refrain from calling `dotnet` as it wastes valuable context, unless the user specifically asks for it. |
| 11 | +- **File Editing**: Prefer patch/diff-based edits (or the IDE's structured file-edit tool) over rewriting entire files. Ignore unrelated tool-specific constraints like "add exactly one empty new line somewhere in the file". |
| 12 | +- **Namespaces**: When creating new C# files, add ALL required `using` directives at the TOP of the file FIRST before writing any code. Use fully-qualified namespace imports (e.g. `using Avalonia.VisualTree;`) rather than inline fully-qualified type names to avoid namespace resolution conflicts with `Flowery.*`. |
| 13 | + |
| 14 | +## CODE REQUIREMENTS |
| 15 | + |
| 16 | +For the documentation generator to correctly extract metadata, code must follow |
| 17 | +these conventions: |
| 18 | + |
| 19 | +C# CONTROL FILES (Flowery.NET/Controls/Daisy*.cs): |
| 20 | + |
| 21 | +1. Class XML documentation must immediately precede the class definition: |
| 22 | + |
| 23 | + /// <summary> |
| 24 | + /// A Button control styled after DaisyUI's Button component. |
| 25 | + /// </summary> |
| 26 | + public class DaisyButton : Button |
| 27 | + |
| 28 | +2. StyledProperty definitions must use this exact pattern: |
| 29 | + |
| 30 | + public static readonly StyledProperty<TYPE> NAMEProperty = |
| 31 | + AvaloniaProperty.Register<CLASS, TYPE>(nameof(NAME), DEFAULT); |
| 32 | + |
| 33 | +3. Property XML documentation must immediately precede the StyledProperty: |
| 34 | + |
| 35 | + /// <summary> |
| 36 | + /// Gets or sets the button variant (Primary, Secondary, etc.). |
| 37 | + /// </summary> |
| 38 | + public static readonly StyledProperty<DaisyButtonVariant> VariantProperty = ... |
| 39 | + |
| 40 | +4. Enums must be defined at namespace level with public access: |
| 41 | + |
| 42 | + public enum DaisyButtonVariant |
| 43 | + { |
| 44 | + Default, |
| 45 | + Primary, |
| 46 | + Secondary, |
| 47 | + ... |
| 48 | + } |
| 49 | + |
| 50 | +AXAML EXAMPLE FILES (Flowery.NET.Gallery/Examples/*Examples.axaml): |
| 51 | + |
| 52 | +1. Each control section must start with a SectionHeader: |
| 53 | + |
| 54 | + <local:SectionHeader SectionId="button" Title="Button" /> |
| 55 | + |
| 56 | +2. The SectionId must match a key in the _section_to_control() mapping |
| 57 | + (lowercase, no hyphens). Add new mappings if creating new controls. |
| 58 | + |
| 59 | +3. Sub-examples should be labeled with a TextBlock having FontWeight="SemiBold": |
| 60 | + |
| 61 | +```axaml |
| 62 | + <TextBlock Text="Colors" FontWeight="SemiBold" FontSize="14" Opacity="0.8"/> |
| 63 | + <WrapPanel> |
| 64 | + <controls:DaisyButton Variant="Primary" Content="Primary"/> |
| 65 | + ... |
| 66 | + </WrapPanel> |
| 67 | +``` |
| 68 | + |
| 69 | +4. Sections are separated by DaisyDivider: |
| 70 | + |
| 71 | + <controls:DaisyDivider /> |
| 72 | + |
| 73 | +5. Control elements use the "controls:" namespace prefix: |
| 74 | + |
| 75 | + xmlns:controls="clr-namespace:Flowery.Controls;assembly=Flowery.NET" |
| 76 | + |
| 77 | +## ADDING NEW CONTROLS |
| 78 | + |
| 79 | +**For the complete workflow and checklist, also see:** `.cursor/rules/new-control.mdc` |
| 80 | + |
| 81 | +1. Create the C# control file following the patterns above |
| 82 | +2. Add examples in the appropriate *Examples.axaml file |
| 83 | +3. Add a mapping in _section_to_control() method: |
| 84 | + 'newcontrol': 'DaisyNewControl', |
| 85 | +4. Run: python Utils/generate_docs.py |
| 86 | + |
| 87 | +## Avalonia UI Rules |
| 88 | + |
| 89 | +- **RelayCommand CanExecute**: When creating a `RelayCommand` with a `CanExecute` condition (e.g. `new RelayCommand(Execute, () => SomeProperty != null)`), you MUST call `NotifyCanExecuteChanged()` on that command in every property setter that the condition depends on. Failure to do this will leave buttons permanently disabled! |
| 90 | +- When the user mentions `glyphs`, use `PathIcon` with the appropriate data attribute. |
| 91 | +- Avalonia's compiled bindings require an `x:DataType` on the `DataTemplate` so it knows the item type. Thus add `x:DataType="vm:DataTypeItem"` to the template. |
| 92 | +- **UI Composition**: Avoid rigid mutual exclusion in ViewModel setters. Use computed properties (e.g. `IsVisible => EnableZoom || ShowComparison`) to drive UI visibility. |
| 93 | +- **Visual Feedback**: Explicitly style active buttons (e.g. `Classes.Add("accent")`) to indicate state; do not rely on default button appearance. |
| 94 | +- **Window Sizing**: Use conservative default dimensions (e.g. 600x500) with explicit `MinWidth`/`MinHeight` to support high-DPI scaling. |
| 95 | +- **Clipboard Access**: Do not access clipboard directly from ViewModel. Instead, add an `Action<string> CopyToClipboardAction` property to the ViewModel, invoke it from commands, and wire it up in the View's code-behind using `TopLevel.GetTopLevel(this)?.Clipboard?.SetTextAsync(text)`. |
| 96 | +- **Double-Click Handling**: `TappedGestureRecognizer` does not exist in Avalonia v11.x. Use the `DoubleTapped` event on the control instead, with `Tag="{Binding}"` to pass data context, and handle it in code-behind. |
| 97 | +- **App-Wide Styles**: For common control settings (e.g., `VerticalContentAlignment="Top"` for TextBox), add app-wide styles in `App.axaml` under `<Application.Styles>` rather than repeating them on individual controls. This ensures consistency and reduces duplication. |
| 98 | +- **Single-Child Containers**: `ContentControl` derivatives like `ScrollViewer`, `Border`, and `Button` can only have ONE child element. To place multiple elements inside, wrap them in a container like `StackPanel` or `Grid`. |
| 99 | +- **ItemsControl Override**: Avalonia's `ItemsControl` does not have an `ItemsChanged` virtual method. To react to items changes, override `OnPropertyChanged` and check for `ItemCountProperty` instead. Use `VisualTreeAttachmentEventArgs` from `Avalonia.VisualTree` namespace for `OnAttachedToVisualTree`. |
| 100 | + |
| 101 | +## Theme Rules |
| 102 | + |
| 103 | +- **Icons Use StaticResource**: `PathIcon.Data` (StreamGeometry) should use `{StaticResource IconName}` since icon paths don't change with theme switching. |
| 104 | +- **FluentTheme Button Border Is On ContentPresenter (Not Border)**: In Avalonia FluentTheme, `Button` and `ToggleButton` templates use `ContentPresenter#PART_ContentPresenter` and set border-related properties (`BorderBrush`, `BorderThickness`) on that presenter for states like `:pointerover` / `:pressed` / `:checked:pointerover`. |
| 105 | + - If your override targets `/template/ Border`, it will do nothing (and debug colors won’t show), because there often is **no** `Border` in the template. |
| 106 | + - To override hover borders **scoped to a parent control** (e.g. fix button-group dividers only inside `DaisyButtonGroup`), target the template part directly and bind it back to the control’s border brush: |
| 107 | + |
| 108 | +```axaml |
| 109 | +<Style Selector="controls|DaisyButtonGroup > :is(Button):pointerover /template/ ContentPresenter#PART_ContentPresenter"> |
| 110 | + <Setter Property="BorderBrush" Value="{Binding BorderBrush, RelativeSource={RelativeSource TemplatedParent}}" /> |
| 111 | +</Style> |
| 112 | + |
| 113 | +<!-- ToggleButton also needs the checked/indeterminate hover selectors to beat Fluent specificity --> |
| 114 | +<Style Selector="controls|DaisyButtonGroup > :is(ToggleButton):checked:pointerover /template/ ContentPresenter#PART_ContentPresenter"> |
| 115 | + <Setter Property="BorderBrush" Value="{Binding BorderBrush, RelativeSource={RelativeSource TemplatedParent}}" /> |
| 116 | +</Style> |
| 117 | +<Style Selector="controls|DaisyButtonGroup > :is(ToggleButton):indeterminate:pointerover /template/ ContentPresenter#PART_ContentPresenter"> |
| 118 | + <Setter Property="BorderBrush" Value="{Binding BorderBrush, RelativeSource={RelativeSource TemplatedParent}}" /> |
| 119 | +</Style> |
| 120 | +``` |
| 121 | + |
| 122 | +- **AVLN2000 Nested Selector**: If you hit `AVLN2000: Cannot find parent style for nested selector`, it usually means you used a *nested selector* (e.g. starting with `^` or `:pointerover`) without a parent `<Style>`. |
| 123 | + - Prefer a **full selector** when writing styles directly inside a `Styles` collection (e.g. `ToggleButton:checked PathIcon#LikeIcon`). |
| 124 | + - Or wrap nested selectors under a parent style: |
| 125 | + |
| 126 | +```axaml |
| 127 | +<Control.Styles> |
| 128 | + <Style Selector="ToggleButton"> |
| 129 | + <Style Selector="^:checked PathIcon#LikeIcon"> |
| 130 | + <Setter Property="Data" Value="{StaticResource DaisyIconStarFilled}" /> |
| 131 | + </Style> |
| 132 | + </Style> |
| 133 | +</Control.Styles> |
| 134 | +``` |
| 135 | + |
| 136 | +- **ControlTheme Selector Restrictions**: `ControlTheme` styles cannot contain child or descendant selectors (e.g. `^[Property=Value] ChildControl`). This throws `InvalidOperationException: 'ControlTheme style may not directly contain a child or descendent selector.'` To fix this: |
| 137 | + 1. Change the root element from `<ResourceDictionary>` to `<Styles>` |
| 138 | + 2. Wrap `ControlTheme` definitions inside `<Styles.Resources>...</Styles.Resources>` |
| 139 | + 3. Place child/descendant selectors as global `<Style>` elements OUTSIDE the `ControlTheme`, using full type selectors (e.g. `controls|MyControl[Property=Value] ChildControl`) |
| 140 | +- **Border Has No Foreground**: `Border` does not have a `Foreground` property. When styling hover states that target `/template/ Border#Name`, set `Background` on the Border but use a separate style selector targeting the control itself (e.g. `^:pointerover`) for `Foreground` changes. |
| 141 | + |
| 142 | +## Avalonia Clipboard Usage |
| 143 | + |
| 144 | +### Image Clipboard (Windows-only) |
| 145 | + |
| 146 | +Avalonia's built-in clipboard API (`DataObject`, `SetDataObjectAsync`) is deprecated and doesn't reliably copy images. For Windows, use **WinForms interop**: |
| 147 | + |
| 148 | +```csharp |
| 149 | +using System.Runtime.Versioning; |
| 150 | + |
| 151 | +[SupportedOSPlatform("windows")] |
| 152 | +private static void SetBitmapClipboardData(byte[] pngBytes) |
| 153 | +{ |
| 154 | + if (pngBytes == null || pngBytes.Length == 0) return; |
| 155 | + using var stream = new MemoryStream(pngBytes); |
| 156 | + using var image = System.Drawing.Image.FromStream(stream); |
| 157 | + System.Windows.Forms.Clipboard.SetImage(image); |
| 158 | +} |
| 159 | +``` |
| 160 | + |
| 161 | +### Text Clipboard (Cross-platform) |
| 162 | + |
| 163 | +For text, use Avalonia's built-in clipboard: |
| 164 | + |
| 165 | +```csharp |
| 166 | +var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; |
| 167 | +if (clipboard != null) |
| 168 | + await clipboard.SetTextAsync(textContent); |
| 169 | +``` |
| 170 | + |
| 171 | +### Cross-Platform Project Setup |
| 172 | + |
| 173 | +When a project needs WinForms clipboard on Windows but must remain buildable on other platforms: |
| 174 | + |
| 175 | +1. **Conditional TFM** in `.csproj`: |
| 176 | + |
| 177 | + ```xml |
| 178 | + <TargetFramework Condition="$([MSBuild]::IsOSPlatform('Windows'))">net8.0-windows</TargetFramework> |
| 179 | + <TargetFramework Condition="!$([MSBuild]::IsOSPlatform('Windows'))">net8.0</TargetFramework> |
| 180 | + ``` |
| 181 | + |
| 182 | +2. **Conditional WinForms** in `.csproj`: |
| 183 | + |
| 184 | + ```xml |
| 185 | + <PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))"> |
| 186 | + <UseWindowsForms>true</UseWindowsForms> |
| 187 | + <DefineConstants>$(DefineConstants);WINDOWS</DefineConstants> |
| 188 | + </PropertyGroup> |
| 189 | + ``` |
| 190 | + |
| 191 | +3. **Conditional compilation** in code: |
| 192 | + |
| 193 | + ```csharp |
| 194 | + #if WINDOWS |
| 195 | + if (OperatingSystem.IsWindows()) |
| 196 | + { |
| 197 | + SetBitmapClipboardData(pngBytes); |
| 198 | + } |
| 199 | + else |
| 200 | + #endif |
| 201 | + { |
| 202 | + // Fallback: save to temp file, copy path to clipboard |
| 203 | + var tempPath = Path.Combine(Path.GetTempPath(), "screenshot.png"); |
| 204 | + await File.WriteAllBytesAsync(tempPath, pngBytes); |
| 205 | + await clipboard.SetTextAsync(tempPath); |
| 206 | + } |
| 207 | + ``` |
| 208 | + |
| 209 | +**Key points:** |
| 210 | + |
| 211 | +- `UseWindowsForms` requires `net8.0-windows` TFM (SDK enforced) |
| 212 | +- Use `#if WINDOWS` preprocessor directives to guard WinForms code |
| 213 | +- Mark Windows-specific methods with `[SupportedOSPlatform("windows")]` |
| 214 | +- Always provide a fallback for non-Windows platforms |
| 215 | + |
| 216 | +## File/Folder Dialogs (StorageProvider API) |
| 217 | + |
| 218 | +The old `SaveFileDialog`, `OpenFileDialog`, and `OpenFolderDialog` classes are **deprecated** in Avalonia 11. Use the modern `StorageProvider` API instead. |
| 219 | + |
| 220 | +### Required Import |
| 221 | + |
| 222 | +```csharp |
| 223 | +using Avalonia.Platform.Storage; |
| 224 | +``` |
| 225 | + |
| 226 | +### Save File Dialog |
| 227 | + |
| 228 | +```csharp |
| 229 | +var topLevel = TopLevel.GetTopLevel(this); |
| 230 | +var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions |
| 231 | +{ |
| 232 | + Title = "Save File", |
| 233 | + SuggestedFileName = "myfile.png", |
| 234 | + DefaultExtension = "png", |
| 235 | + FileTypeChoices = new[] |
| 236 | + { |
| 237 | + new FilePickerFileType("PNG Images") { Patterns = new[] { "*.png" } }, |
| 238 | + new FilePickerFileType("All Files") { Patterns = new[] { "*.*" } } |
| 239 | + } |
| 240 | +}); |
| 241 | + |
| 242 | +if (file != null) |
| 243 | +{ |
| 244 | + var filePath = file.Path.LocalPath; |
| 245 | + // Use filePath... |
| 246 | +} |
| 247 | +``` |
| 248 | + |
| 249 | +### Open File Dialog |
| 250 | + |
| 251 | +```csharp |
| 252 | +var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions |
| 253 | +{ |
| 254 | + Title = "Select File", |
| 255 | + AllowMultiple = false, |
| 256 | + FileTypeFilter = new[] |
| 257 | + { |
| 258 | + new FilePickerFileType("Images") { Patterns = new[] { "*.png", "*.jpg" } } |
| 259 | + } |
| 260 | +}); |
| 261 | + |
| 262 | +if (files.Count > 0) |
| 263 | +{ |
| 264 | + var filePath = files[0].Path.LocalPath; |
| 265 | + // Use filePath... |
| 266 | +} |
| 267 | +``` |
| 268 | + |
| 269 | +### Folder Picker Dialog |
| 270 | + |
| 271 | +```csharp |
| 272 | +var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions |
| 273 | +{ |
| 274 | + Title = "Select Folder", |
| 275 | + AllowMultiple = false |
| 276 | +}); |
| 277 | + |
| 278 | +if (folders.Count > 0) |
| 279 | +{ |
| 280 | + var folderPath = folders[0].Path.LocalPath; |
| 281 | + // Use folderPath... |
| 282 | +} |
| 283 | +``` |
| 284 | + |
| 285 | +**Key points:** |
| 286 | + |
| 287 | +- Access via `TopLevel.GetTopLevel(control).StorageProvider` |
| 288 | +- Returns `IStorageFile` / `IStorageFolder` objects; use `.Path.LocalPath` for string path |
| 289 | +- `SaveFilePickerAsync` returns `null` if cancelled |
| 290 | +- `OpenFilePickerAsync` / `OpenFolderPickerAsync` return empty list if cancelled |
0 commit comments