diff --git a/.gitignore b/.gitignore index 338efb5..c68aa6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,440 +1,444 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates -*.env - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ - -[Dd]ebug/x64/ -[Dd]ebugPublic/x64/ -[Rr]elease/x64/ -[Rr]eleases/x64/ -bin/x64/ -obj/x64/ - -[Dd]ebug/x86/ -[Dd]ebugPublic/x86/ -[Rr]elease/x86/ -[Rr]eleases/x86/ -bin/x86/ -obj/x86/ - -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -[Aa][Rr][Mm]64[Ee][Cc]/ -bld/ -[Oo]bj/ -[Oo]ut/ -[Ll]og/ -[Ll]ogs/ - -# Build results on 'Bin' directories -**/[Bb]in/* -# Uncomment if you have tasks that rely on *.refresh files to move binaries -# (https://github.com/github/gitignore/pull/3736) -#!**/[Bb]in/*.refresh - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* -*.trx - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Approval Tests result files -*.received.* - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ -.artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.idb -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -**/.paket/paket.exe -paket-files/ - -# FAKE - F# Make -**/.fake/ - -# CodeRush personal settings -**/.cr/personal - -# Python Tools for Visual Studio (PTVS) -**/__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -#tools/** -#!tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog -MSBuild_Logs/ - -# AWS SAM Build and Temporary Artifacts folder -.aws-sam - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -**/.mfractor/ - -# Local History for Visual Studio -**/.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -**/.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets - -# Local History for Visual Studio Code -.history/ - -# Built Visual Studio Code Extensions -*.vsix - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# Generated .ico files from template builds -src/ProjectTemplates/**/*.ico - -# Plan, idea, proposal files -plan* -proposal* -idea* - -# OpenCode .ignore file -.ignore +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ + +[Dd]ebug/x64/ +[Dd]ebugPublic/x64/ +[Rr]elease/x64/ +[Rr]eleases/x64/ +bin/x64/ +obj/x64/ + +[Dd]ebug/x86/ +[Dd]ebugPublic/x86/ +[Rr]elease/x86/ +[Rr]eleases/x86/ +bin/x86/ +obj/x86/ + +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +.artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# Generated .ico files from template builds +src/ProjectTemplates/**/*.ico + +# Plan, idea, proposal files +plan* +proposal* +idea* + +# OpenCode .ignore file +.ignore + + + +roadmap.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6b5a4e..17bef9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,15 +67,18 @@ El framework está organizado en módulos bajo `src/EasyWindowsApplication/`: | Módulo | Capa | Responsabilidad | |---|---|---| -| `Core` | `internal` (100%) | Núcleo del framework: `WindowsApplication` → `Share/IApplicationLayoutPhase`, `Application` (pipeline `Initialize()` en 5 fases), `MasterRouter` (trampolín `[UnmanagedCallersOnly]` + `HandleRegistry` `ConcurrentDictionary` + 6 diccionarios adicionales; ciclo `WM.CLOSE/SIZE/MOVE/ACTIVATE/SCROLL/DPI/ERASEBKGND/CTLCOLOR/DESTROY`), `HandleRegistry` (hwnd→control/window, parent→children), `ControlBase`, `IconGenerator`, `Procedures.RunMessageLoop()` (`GetMessageW/TranslateMessage/DispatchMessageW`), `Constants.cs` (227 líneas: `WS/WS_EX/CS/CW/WM/SPI/WMSIZE/WA/MONITOR/DT/SCROLLBAR/SIF/BN/EN/SW/GWL/ICC/SWP`), `Entities.cs` (`MSG/POINT/NMHDR/SCROLLINFO/…`), `Win32.cs` (~40 P/Invoke `[LibraryImport]` para Native AOT), `LayoutModels.cs` (`WindowModel/ContentModel/ViewModel`), `ResourcesDictionaryImpl.cs` (`SettingsBuilderImpl/UseWinApi` no-op + `AppConfigFile/Services` builders) | +| `Core` | `internal` (100%) | Núcleo del framework: `WindowsApplication` → `Share/IApplicationLayoutPhase`, `Application` (`Initialize()` → `Core/Threading/UiApartment` (re hospedaje en hilo STA `EWA-UI` cuando es necesario) → `RunPipeline()`, ver Decisiones de diseño; `RegisterAlternative` con 3 ramas: `ISystemDialog` → `RegisterAlternativeDialog` sin HWND, `IMenu` → `MenuSurface`, resto → HWND alternativa), `MasterRouter` (trampolín `[UnmanagedCallersOnly]` + `HandleRegistry` `ConcurrentDictionary` estático (plumbing WndProc AOT, **fuera del modelo**) + `CleanupHwnd(nint)` (línea de ayuda WM_DESTROY, #51) + 4 diccionarios de recursos GDI/scroll; ciclo `WM.CLOSE/SIZE/MOVE/ACTIVATE/SCROLL/DPI/ERASEBKGND/CTLCOLOR/DESTROY`; el catch del trampolín registra en `Trace` (visible en Release) además de `Debug.Fail` (solo DEBUG), sin tumbar la message loop), `HandleRegistry` (**almacén único de entidades** con índices derivados: `_entries` (Id→Entry), `_byHwnd`, `_byName`, grafo `ChildrenIds`; verbos `Attach/DetachHandle/Remove/DetachWindowByHwnd(policy)`; fachada `RegisterWindow` upsert por nombre para re-creación de secundarias), `ControlBase`, `IconGenerator`, `Procedures.RunMessageLoop()` (`GetMessageW/TranslateMessage/DispatchMessageW`), `Constants.cs` (227 líneas: `WS/WS_EX/CS/CW/WM/SPI/WMSIZE/WA/MONITOR/DT/SCROLLBAR/SIF/BN/EN/SW/GWL/ICC/SWP`), `Entities.cs` (`MSG/POINT/NMHDR/SCROLLINFO/…`), `Win32.cs` (~40 P/Invoke `[LibraryImport]` para Native AOT), `LayoutModels.cs` (`WindowModel/ContentModel/ViewModel`), `ResourcesDictionaryImpl.cs` (`SettingsBuilderImpl/UseWinApi` no-op + `AppConfigFile/Services` builders) | | `Core/UiDefaults` | `internal` | Sistema centralizado de defaults con DPI scaling: `IDefaultUiValues`, `UiDefaultsProvider` (singleton `CoreFallbackDefaults` → `Set(Win32UiDefaults)` en `Application.Initialize()` antes de registrar ventanas), `FontSpec` (`SystemTheme`), `ControlUiDefaults`/`WindowUiDefaults`/`CoreUiDefaults` (`PreferredHeight/Padding/Background`), `DefaultUiValuesExtensions`. Acoplamiento crítico: `Button.MeasureContent()` lee `UiDefaultsProvider.Current.GetFor().PreferredHeight` y escala `96→dpiActual`; `ControlProcedures.GetDefaultFont()` lee `UiDefaultsProvider.Current.DefaultFont` → `CreateFontFromSpec` | | `Core/Windowing` | `internal` | `WindowImpl`, `AlternativeWindowImpl`, `Procedures` (`CreateMainWindow` con `WS_OVERLAPPEDWINDOW` + `CreateAlternativeWindow` con `WS_POPUP|CAPTION|SYSMENU`, registro `HandleRegistry.RegisterRouter` + `WndProcTrampoline`), `UserControl`, `Win32.cs` (P/Invoke parcial `[LibraryImport]`), `Entities.cs` (`MSG/POINT/NMHDR/…`), `Enums.cs`, `Constants.cs` (stub: "Window-specific constants are in Core") | | `Core/LayoutEngine` | `internal` | `LayoutEngine` (batching `DeferWindowPos` anti-flicker), `ILayoutStrategy`/`ILayoutable`, `LayoutBuilderImpl`, `ContentBuilderImpl`, `WindowConfigBuilder`, `ChildrenBuilderImpl` (usa `ControlActivatorRegistry`), `ViewBuilderImpl`, estrategias `GridLayoutStrategy`/`DockLayoutStrategy`/`HorizontalStackLayoutStrategy`/`VerticalStackLayoutStrategy` | +| `Core/Threading` | `internal` (100%) | `UiApartment.RunStaOrCurrent(Action)`: fontanería de apartment COM — si el hilo llamante es STA corre en él; si es MTA crea el `Thread` STA **`EWA-UI`** y hace `Join()` (sin `async`/`Task`/`SynchronizationContext`; la API pública nunca expone concurrencia) | +| `Core/Dialogs` | `internal` (100%) | Diálogos del sistema (`ISystemDialog`): `DialogSurfaceBase` (base `ISystemDialog`: fluent `Name/Title` + props `IViewSurface` inertes + `OwnerProvider` lazy `() => _mainHwnd`), `TaskDialogImpl` (`Text` mínimo vs `Content(builder)` con `PrimaryText`/`SecondaryText`, `NotificationIcon`, `Choices`/`DefaultChoice` con `TaskDialogChoice`) + `TaskDialogInterop` (`TaskDialogIndirect` con `TASKDIALOGCONFIG` **`Pack = 4`**, ver Decisiones de diseño), `OpenFileDialogImpl`/`SaveFileDialogImpl`/`SelectFolderDialogImpl` (cada uno `Content(builder)` con sus campos internos; `Filters` top-level en Open/Save) + `Com/FileDialogCom.cs` (`ShowFileDialog` con `persistLastDirectory` → `SetClientGuid(Guid.NewGuid())` si `false`) + `Com/FileDialogNative.cs` (motor COM unificado `IFileDialog`/`IFileOpenDialog`/`IFileSaveDialog` por vtable manual, AOT-safe, sin dependencias) | +| `Share/Dialogs` | `public` | API de diálogos: `ISystemDialog : IViewSurface` (marker + fluent), `IOpenFileDialog` (+`IOpenFileDialogContentBuilder`: `DefaultDirectory`/`MultiSelect`)/`ISaveFileDialog` (+`ISaveFileDialogContentBuilder`: `DefaultDirectory`/`DefaultFileName`/`DefaultExtension(FilePattern)`)/`ISelectFolderDialog` (+`ISelectFolderDialogContentBuilder`: `DefaultDirectory`/`PersistLastDirectory`)/`ITaskDialog` (+`ITaskDialogContentBuilder`), resultados tipados (`DialogResults.cs`: `FileOpenResult`/`FileSaveResult`/`FolderSelectResult`/`TaskDialogResult`), `FilePattern` (record struct composite con `DefaultExtension` computada), `IFileDialogFiltersBuilder`/`IFileFilterChildrenBuilder` (espejo de `IContentBuilder.Children`), enums `TaskDialogIcon`/`TaskDialogChoice` (escalar) vs `TaskDialogChoices` (`[Flags]`) | | `Common` | `internal` | `ControlActivatorRegistry` (`Register/Create`, `RegisterFactory/TryGetFactory/CreateFactory/TryGetFactoryForControl`, `Shared` + `EnsureInitialized()` + `partial RegisterGeneratedActivators()`), `IControlActivator` (`RegisterActivators`), `INativeHandleFactory` (`CreateHandle(parentHwnd, control, registry)`), `Win32Helpers` (`HIWORD/LOWORD` usado por `MasterRouter`). Rompe ciclo `Common ↔ Core` sin `ModuleInitializer` (`CA2255`); usa `IIncrementalGenerator` + `InternalsVisibleTo` | -| `Share` | `public` | API usuario: jerarquía ventanas `IBaseWindow` → `IWindow`/`IAlternativeWindow`/`IView`, `IWindowConfig`/`IContentBuilder`/`ILayout`/`ILayoutBuilderAfterWindow.AlternativeWindow()`/`IViewBuilder` (controles custom), `ViewBase` (`Share/View.cs`, base para controles custom ej. `IpAddress`) + `View sealed class where T : class, IControl` (`Name/Margin/Padding/Width/Height/Background/Dock/OnClick/Text` + `PendingName` y lista `Action`), `IChildrenBuilder` (3 overloads: `View(Action>)` + `View(Func,View>)` + `View(Action)`), primitivas `LayoutLength`/`GridDefinitions`/`Thickness`/`Color`/`LayoutOptions`/`DockPosition`/`WindowPositionOnScreen`/`ScrollBarVisibility`, eventos `CancelEventArgs/WindowMoved/Resized/Resizing`, `IResourcesDictionary`/`IApplicationLayoutPhase` (Type-State) | +| `Share` | `public` | API usuario: jerarquía ventanas `IBaseWindow` → `IWindow`/`IAlternativeWindow`/`IView`, `IWindowConfig`/`IContentBuilder`/`ILayout`/`ILayoutBuilderAfterWindow.AlternativeWindow()`/`IViewBuilder` (controles custom), `ViewBase` (`Share/View.cs`, base para controles custom ej. `IpAddress`) + `View sealed class where T : class, IControl` (`Name/Margin/Padding/Width/Height/Background/Dock/Text` + `PendingName` y lista `Action`), `IChildrenBuilder` (3 overloads: `View(Action>)` + `View(Func,View>)` + `View(Action)`), primitivas `LayoutLength`/`GridDefinitions`/`Thickness`/`Color`/`LayoutOptions`/`DockPosition`/`WindowPositionOnScreen`/`ScrollBarVisibility`, eventos `CancelEventArgs/WindowMoved/Resized/Resizing`, `IResourcesDictionary`/`IApplicationLayoutPhase` (Type-State) | | `Share/Infrastructure` | `public` técnico | `[EditorBrowsable(Never)]`: `ControlAccess` (punto usado por `EasyBehaviorGenerator` vía `ControlAccess.Get(name)` / `GetWindow`) — único tipo en este namespace | | `Win32ControlsModule` | Frontend `public` / Backend `internal` | **Frontend:** 30 interfaces `IControl` → `IButton/ILabel/ICheckBox/IComboBox/…/ITreeView/IUpDown` + `ControlBuilderExtensions` + `StyleInterfaces` + clase `IpAddress : ViewBase` · **Backend:** `Button`/`Label` → `Core.ControlBase` (con `MeasureContent` + DPI scaling acoplado a `UiDefaultsProvider`), `Win32UiDefaults` (`IDefaultUiValues` concreto registrado en `Application.Initialize()`), `Win32NativeHandleFactory` (`INativeHandleFactory`), `Win32ControlActivator` (`IControlActivator`, `internal`, auto-registrado vía `ControlActivatorGenerator` + `EnsureInitialized()`), `Procedures.ControlProcedures` (capa GDI/User32: `GetDefaultFont/CreateFontFromSpec/GetDpiForWindowSafe/SendMessage/InvalidateDefaultFont/CreateControl`), `Win32.cs`/`Constants.cs`/`Entities.cs` del módulo | -| `Generators` | `Analyzer` | `EasyBehaviorGenerator` (detecta `.Name("…")` en `View/Window/AlternativeWindow`, genera `EasyBehaviorExtensions` + `EAWIN001` duplicados + `EAWIN002` gate `UseWinApi()`, FQN `Share` + `GetTypeByMetadataName`) + `ControlActivatorGenerator` (descubre `IControlActivator` vía `AllInterfaces`, genera `ControlActivatorRegistrations.g.cs` con `new Win32ControlActivator().RegisterActivators(Shared)`, `InternalsVisibleTo`) | +| `Generators` | `Analyzer` | Cuatro `IIncrementalGenerator`: `EasyBehaviorGenerator` (detecta `.Name("…")` en `View/Window/AlternativeWindow`, genera `EasyBehaviorExtensions` con lookups `ControlAccess.Get`/`GetWindow`/`GetSurface` + `EAWIN001` duplicados + `EAWIN002` gate `UseWinApi()`, FQN `Share` + `GetTypeByMetadataName`), `ControlActivatorGenerator` (descubre `IControlActivator` vía `AllInterfaces`, genera `ControlActivatorRegistrations.g.cs`, `InternalsVisibleTo`), `CulturesGenerator` (catálogo `Cultures` curado + `global using static` → presets pelados `CultureInfoEnUS`), `FilePatternsGenerator` (catálogo `FilePatterns` ~59 presets `FilePattern` composite + `global using static` → uso pelado `PngFile`) | ### Principios de organización @@ -182,14 +185,17 @@ Documenta aquí las decisiones de arquitectura relevantes. Usa ADRs (Architectur - **Fluent API con interfaces de fase (Type-State)** — El IntelliSense guía el orden `Resources → Layout → Behavior → Initialize` mediante interfaces (`IApplicationLayoutPhase`, `IApplicationPostLayoutPhase`, `IApplicationPostBehaviorPhase`) ahora en `Share/` (antes `CoreModule/Frontend`). `WindowsApplication` devuelve `IApplicationLayoutPhase` público. `IApplicationPostLayoutPhase` expone tanto `Behavior()` como `Initialize()` para flujos mínimos (`Resources→Layout→Initialize` o `Layout→Initialize`). `Application.cs` tiene overloads sin parámetro `Layout()`/`Behavior()` que retornan `this` para encadenamiento condicional. `WindowsApplication` tiene `static constructor` que llama `ControlActivatorRegistry.EnsureInitialized()` (inicialización perezosa; segundo llamado en `Application.Initialize()` es no-op por flag `_inited`). - **Controles como interfaces** (`IButton`, `ILabel`, ...) — permite que el Source Generator genere tipos débilesmente acoplados y facilita testing/mocking. `Button`/`Label` heredan `Core.ControlBase` (antes `Share.Infrastructure.ControlBase`) y contienen lógica DPI (`MeasureContent` escala `PreferredHeight` 96→dpi actual vía `UiDefaultsProvider`). -- **MasterRouter** — 376 líneas: centraliza el bucle de mensajes con trampolín `[UnmanagedCallersOnly(CallConvs=[Stdcall])]` + lookup HWND-based (`HandleRegistry.ConcurrentDictionary`), `try/catch` que evita tumbar proceso, ciclo completo `WM.CLOSE/ENTERSIZEMOVE/EXITSIZEMOVE/SIZE/MOVE/ACTIVATE/HSCROLL/VSCROLL/MOUSEWHEEL/SETTINGCHANGE/DPICHANGED/ERASEBKGND/CTLCOLOR/DESTROY` (typed dispatch `WM.COMMAND→Click` + raw handlers + cleanup `Unregister/PostQuitMessage` + `WM.ERASEBKGND` con `LayoutGroup` backgrounds + `WM.CTLCOLOR*` con control brushes), despacha eventos tipados (`WM.COMMAND` → `Click`). El delegado estático sobrescrito fue eliminado (evita GC). Paint/Theme/DPI/scroll/lifecycle separados del GDI. -- **HandleRegistry** — No es solo `ConcurrentDictionary`. Contiene `ConcurrentDictionary` (routers) + 5 diccionarios: `hwnd→WeakReference`, `name→WeakReference`, `name→IBaseWindow`, `hwnd→IBaseWindow`, `parent→List children`. Métodos: `TrackChildWindow/UnregisterWindowControls/RegisterWindow/UnregisterWindow/UnregisterWindowByHwnd/GetWindowByHwnd/GetByName/GetWindow/AllControlHandles`. +- **MasterRouter** — 376 líneas: centraliza el bucle de mensajes con trampolín `[UnmanagedCallersOnly(CallConvs=[Stdcall])]` + lookup HWND-based (`HandleRegistry.ConcurrentDictionary`), `try/catch` que evita tumbar proceso, ciclo completo `WM.CLOSE/ENTERSIZEMOVE/EXITSIZEMOVE/SIZE/MOVE/ACTIVATE/HSCROLL/VSCROLL/MOUSEWHEEL/SETTINGCHANGE/DPICHANGED/ERASEBKGND/CTLCOLOR/DESTROY` (typed dispatch `WM.COMMAND→MainTap` + raw handlers + cleanup `Unregister/PostQuitMessage` + `WM.ERASEBKGND` con `LayoutGroup` backgrounds + `WM.CTLCOLOR*` con control brushes), despacha entradas tipadas (`WM.COMMAND` → `IInputFeed.FeedTrigger(MainTap)`). El delegado estático sobrescrito fue eliminado (evita GC). Paint/Theme/DPI/scroll/lifecycle separados del GDI. +- **HandleRegistry** — Almacén único de entidades (`Entry { Id, Kind, Hwnd, Name, Target, ChildrenIds }`) con índices derivados `_byHwnd`/`_byName`. **Verb the policy**: `Attach(kind, target, hwnd, name, parentHwnd)` (alta atómica), `DetachHandle(id)` (hwnd OS muere, identidad vive — secundarias), `Remove(id)` (identidad muere, cascada sobre hijos), `DetachWindowByHwnd(hwnd, policy)` (dispatcher `WM_DESTROY`: `Remove` para principal, `DetachHandle` para secundarias). Controles con `WeakReference` (dueño = modelo layout), ventanas/superficies fuertes. Fachada `RegisterWindow` = upsert por nombre (secundarias re-crean con mismo `Name`, nuevo `Hwnd`). Jerarquía en grafo `ChildrenIds` (ventana → controles), no diccionario paralelo. El trampolín `_routersByHwnd` queda **estático** (plumbing WndProc, fuera del modelo). Tras teardown (`_registry.RemoveAll()` en `Initialize()`) `Debug.Assert` de tabla vacía (leak = error). - **ControlAccess / ControlActivatorRegistry / ControlActivatorGenerator** — `ControlAccess` sigue en `Share/Infrastructure` (usado por `EasyBehaviorGenerator`). `ControlActivatorRegistry` en `Common/` (genérico `Register/Create` + `RegisterFactory/TryGetFactory/CreateFactory/CreateFactoryFor/TryGetFactoryForControl` + `INativeHandleFactory` + `Shared` + `EnsureInitialized()` + `partial RegisterGeneratedActivators()`) rompe acoplamiento `Core → Win32ControlsModule` sin `ModuleInitializer` (`CA2255` en `Library`). `ControlActivatorGenerator` (`IIncrementalGenerator` en `Generators`, descubre `IControlActivator` en `Compilation` vía `GetTypeByMetadataName` + `AllInterfaces`, genera `ControlActivatorRegistrations.g.cs` con `new Win32ControlActivator().RegisterActivators(Shared)` y futuros plugins; usa `` para mantener `internal`. -- **View sealed class** — `Share/View` `public sealed class where T : class, IControl` (no `struct`; semántica de referencia en heap, GC, identidad por referencia) con lista `Action` + fluents (`Name/Margin/Padding/Width/Height/Background/Dock/OnClick/Text`). Triple overload en `IChildrenBuilder`: `View(Action>)` + `View(Func,View>)` + `View(Action)` para controles custom sin tipo genérico, instanciando `View(control)` vía `ControlActivatorRegistry`. +- **View sealed class** — `Share/View` `public sealed class where T : class, IControl` (no `struct`; semántica de referencia en heap, GC, identidad por referencia) con lista `Action` + fluents (`Name/Margin/Padding/Width/Height/Background/Dock/Text`). Triple overload en `IChildrenBuilder`: `View(Action>)` + `View(Func,View>)` + `View(Action)` para controles custom sin tipo genérico, instanciando `View(control)` vía `ControlActivatorRegistry`. - **`UseWinApi()` como bifurcación explícita** — gate **solo compile-time** (`EAWIN002`): `SettingsBuilderImpl.UseWinApi()` retorna `this` (no-op en runtime). El Source Generator decide si emite accessors tipados. El mecanismo runtime real es `Core/UiDefaults` (`UiDefaultsProvider.Set(new Win32UiDefaults())` en `Application.Initialize()` antes de `InitCommonControlsEx` y registro de ventanas). `UseWinApi()` y `UiDefaults` son conceptos separados. `IconGenerator` migró `Share/Infrastructure` → `Core/` (ahora `Core.IconGenerator`, IcoGen usa `EasyWindowsApplication.Core`). - **UiDefaults y DPI** — `Core/UiDefaults/` (7 archivos) + `Backend/Win32UiDefaults` + `Backend/Controls.cs` (`Button.MeasureContent` escala `PreferredHeight` vía `UiDefaultsProvider.Current.GetFor()` y `GetDpiForWindow` 96→actual; `ControlProcedures.GetDefaultFont` lee `FontSpec` vía `UiDefaultsProvider.Current.DefaultFont` → `CreateFontFromSpec` → `GetDpiForSystemSafe`). Ver diagrama en [Arquitectura de módulos](#arquitectura-de-modulos). - **Separación Procedures** — `Core/Procedures.cs` (`RunMessageLoop`: `while(GetMessageW>0){Translate/Dispatch}`) pertenece al núcleo; `Core/Windowing/Procedures.cs` (`CreateMainWindow/CreateAlternativeWindow` con `RegisterClassExW` + `WndProcTrampoline`) pertenece a windowing; `Win32ControlsModule/Backend/Procedures.cs` (`ControlProcedures`: `GetDefaultFont/CreateFontFromSpec/GetDpiForWindowSafe/SendMessage/CreateControl`) es la capa GDI/User32. Mezclar message loop con GDI es el error Win32 más común — esta separación debe preservarse. - **AlternativeWindow** — ahora funcional: `Procedures.CreateAlternativeWindow` registra clase `WS_POPUP|CAPTION|SYSMENU` y `HandleRegistry.RegisterRouter(hwnd, router)`; `Application.RegisterAlternative` crea hwnd real, aplica BackgroundBrush y `Show()`. +- **Diálogos del sistema (`ISystemDialog`)** — 4º overload `AlternativeWindow(Action) where TDialog : class, ISystemDialog`: superficies sin HWND, registro vía `RegisterAlternativeDialog`, `OwnerProvider = () => _mainHwnd` (lazy), `Name` obligatorio. Estructura común: top-level = identidad (`Name`/`Title`) + estructural no-visual (`Filters` en Open/Save); `Content(builder)` = visual/comportamiento. `ITaskDialog` tiene dos vías: `.Text(string)` (mínimo: defaults Ok/sin icono) y `.Content(builder)` con `ITaskDialogContentBuilder` (`NotificationIcon`, `PrimaryText`, `SecondaryText`, `Choices`/`DefaultChoice` con enums `TaskDialogChoices`/`TaskDialogChoice`, resultado `TaskDialogResult.Choice`). `IOpenFileDialog.Content` (`DefaultDirectory`, `MultiSelect`), `ISaveFileDialog.Content` (`DefaultDirectory`, `DefaultFileName`, `DefaultExtension(FilePattern)`) — usa `FilePattern.DefaultExtension` (propiedad computada que extrae la primera extensión del primer filtro, p.ej. `PngFile` → `"png"`), eliminando strings mágicos y alineando semántica con `FileFilter`; `ISelectFolderDialog.Content` (`DefaultDirectory`, `PersistLastDirectory` nuevo: `false` → `ClientGuid` único por `Show()` vía `SetClientGuid`, fuerza `DefaultDirectory`). `Buttons` reservado para futuros botones custom (`TASKDIALOG_BUTTON[]`). `Text` elegido sobre `Content/Body/Message` por consistencia con `ILabel.Text`/`IButton.Text`; `Content` en EWA siempre recibe builder (`Children`). `NotificationIcon` vs `Icon` evita colisión semántica. +- **`TASKDIALOGCONFIG` requiere `Pack = 4`** (lección aprendida): la ABI efectiva de esta struct usa packing de 4 (verificado empiricamente contra el struct generado por CsWin32 que usa `System.Windows.Forms.TaskDialog`), no packing natural de 8. Con `LayoutKind.Sequential` por defecto el CLR produce tamaño 176 y punteros en offsets 8-alineados (p. ej. `pszWindowTitle` en 32), pero la API espera 160 bytes y offsets 4-alineados (`pszWindowTitle` en 28) → `TaskDialogIndirect` devuelve `E_INVALIDARG (0x80070057)` en todo caso, silenciosamente. La firma de verificación es: `Marshal.SizeOf` debe dar 160. Cualquier struct nueva de comctl32 debe validarse contra este patrón antes de darla por buena. +- **Hilo de UI STA propio (`EWA-UI`) sin concurrencia visible** — Los diálogos del shell (`IFileOpenDialog`/`IFileSaveDialog`/`FOS_PICKFOLDERS`) exigen STA y el hilo de entrada de .NET es MTA por defecto (`Thread.ApartmentState`: el main thread arranca en MTA; `[STAThread]` solo aplica al entry point con `class Program`, no a top-level statements). Como el apartment COM es inmutable (`CoInitializeEx(STA)` sobre MTA devuelve `RPC_E_CHANGED_MODE`), `Application.Initialize()` delega en `Core/Threading/UiApartment.RunStaOrCurrent(RunPipeline)`: si el llamante ya es STA corre en su hilo; si es MTA, crea un `Thread` STA llamado `EWA-UI`, ejecuta todo el pipeline ahí y hace `Join()`. Consecuencias: **cero** `async`/`await`/`Task`/`SynchronizationContext` en la API pública (decisión de diseño: la fontanería de hilos nunca se expone al producto); los HWND, menús, bandeja, behaviors y el message loop viven en `EWA-UI` (diálogo y owner en el mismo hilo, sin cross-thread); `.Layout(...)`/`.Resources(...)` corren en el hilo de entrada (solo object graph, sin HWND/COM); el hilo de entrada espera durante toda la vida de la app y el proceso termina cuando `EWA-UI` sale (mismo comportamiento que antes del cambio). `Debug.Fail` solo existe en DEBUG, así que el trampolín del router además registra en `Trace.WriteLine` (visible en Release) antes de seguir despachando: una excepción en un handler nunca tumba la app, pero tampoco queda en silencio. --- @@ -275,3 +281,36 @@ Setup manual (una sola vez): --- *Este CONTRIBUTING.md es una plantilla. Sustituye los bloques ``, `` y los enlaces de wiki cuando los documentes de Wiki y ADRs estén disponibles.* + +--- + +## Dialecto Behavior-first para ciclo de vida/estado + +**Regla:** En `IBehaviorBuilder` (API `Behavior`) **no se usa `+=` ni `EventHandler`** para suscribirse a eventos de ciclo de vida/estado de ventanas ni de la aplicación. + +- ✅ **Correcto (Behavior-first, métodos `OnX` con `Action`):** + ```csharp + bh.WindowsApplication.OnTerminated(() => ...); + bh.OtherWindow.OnLoaded(() => ...); + bh.OtherWindow.OnClosing(e => e.Cancel = true); // veto disponible + bh.MainWindow.OnResizing(args => ...); + ``` + +- ❌ **Incorrecto (eventos C# legacy — sí existen, pero NO son el dialecto recomendado):** + ```csharp + bh.OtherWindow.Loaded += (s, e) => ...; + bh.OtherWindow.Closing += (s, e) => ...; + bh.OtherWindow.Closed += (s, e) => ...; + ``` + +**Rationale:** El dialecto `OnX(Action)` oculta `sender`/`EventArgs`, alinea con `IAppBehavior` (`OnLaunched/OnTerminating/OnTerminated`), y evita ambigüedad de overloads. Los eventos C# permanecen como **mecanismo interno** (usados por `RaiseX` en `WindowImpl`/`AlternativeWindowImpl`/`Application`), no como API pública para consumidores. + +--- + +## Teardown de app: sin callbacks de Behavior + +**Regla (#55):** Al cerrar la app (X de la principal / `Terminate()`), el framework ejecuta teardown determinístico: destruye las secundarias vivas **sin disparar `OnClosing` ni `OnClosed`**. Es un apagado de proceso, no un cierre de ventana: ningún callback de Behavior debe correr sobre UI ya parcialmente destruida (p.ej. acceder a controles de la principal desde `OnClosed` de una secundaria). + +- Durante el ciclo de vida normal: `OnClosing` (veto) y `OnClosed` funcionan en cada cierre de secundaria (`Close()` con app viva). +- Para código de apagado de la app completa, use `bh.WindowsApplication.OnTerminated(...)` (no llega a través del pipeline de ventanas, corre fuera del loop principal). +- Implementación: `AlternativeWindowImpl.MarkTearingDown()` (llamado por `Application.RunPipeline` epílogo pre-`DestroyWindow`) + guardas en `RaiseClosing`/`RaiseClosed`. diff --git a/README.md b/README.md index b076bd0..b0ca0f8 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,6 @@ Crear una aplicación nativa nunca fue tan limpio. Todo se divide en 4 bloques lógicos: `Resources`, `Layout`, `Behavior` e `Initialize`. ```csharp -using EasyWindowsApplication; -using EasyWindowsApplication.Share; -using EasyWindowsApplication.Win32ControlsModule.Frontend; - int counter = 0; WindowsApplication @@ -29,13 +25,11 @@ WindowsApplication .Window(iw => iw .Name("MainWindow") .Title("Mi App") - .Dimensions(800, 600) - .Position(WindowPositionOnScreen.Center) .Content(c => c .Children(ch => ch .View(btn => btn - .Name("BtnGuardar") - .Text("Contador: 0") + .Name("BtnIncrease") + .Text("Value: 0") ) ) ) @@ -43,11 +37,10 @@ WindowsApplication ) .Behavior(bh => { - var btn = bh.BtnGuardar; // tipado por Source Generator (View wrapper) - btn.OnClick(() => + btn.BtnIncrease.OnInputWithSpatialPositio(() => { counter++; - btn.Text = $"Contador: {counter}"; + btn.Text = $"Value: {counter}"; }); }) .Initialize(); @@ -137,7 +130,6 @@ WindowsApplication .Resources(rd => rd .Setting(s => s .UseWinApi() - .AppConfigFile(c => c.Path("./appsettings.json").WithAutoSave()) ) .Services(s => s.Singleton()) ) @@ -153,17 +145,12 @@ WindowsApplication EasyWindowsApplication/src/ ├── EasyWindowsApplication/ # Framework principal │ ├── Core/ # Fases Fluent API, Resources, Behavior, MasterRouter (100% internal) -│ │ ├── Constants.cs / Entities.cs / Win32.cs # Interop Win32 central ([LibraryImport], MSG/POINT/WM/WS/…) -│ │ ├── Procedures.cs # RunMessageLoop (GetMessageW/Translate/Dispatch) -│ │ ├── UiDefaults/ # IDefaultUiValues + UiDefaultsProvider + FontSpec (DPI scaling) -│ │ ├── Windowing/ # WindowImpl, AlternativeWindowImpl, Procedures.CreateMainWindow/Alternative, Win32/Entities/Enums, UserControl -│ │ └── LayoutEngine/ # ILayoutBuilder, Grid/Stack/Dock layouts, ViewBuilder, ContentBuilder, ILayoutStrategy │ ├── Common/ # ControlActivatorRegistry + IControlActivator + INativeHandleFactory + Win32Helpers (HIWORD/LOWORD) │ ├── Share/ # API pública usuario (IBaseWindow→IWindow/IAlternativeWindow/IView, IWindowConfig, IChildrenBuilder con 3 overloads View, View sealed class + ViewBase, Color/Thickness/LayoutLength/GridDefinitions) -│ │ └── Infrastructure/ # Técnicos [EditorBrowsable(Never)]: ControlAccess (único tipo aquí) │ ├── Win32ControlsModule/ # Frontend: IButton/ILabel/… (30 interfaces) + Backend: Button/Label→Core.ControlBase + Win32UiDefaults/Win32NativeHandleFactory/ControlProcedures (GDI/User32) │ └── WindowsApplication.cs # Punto de entrada Fluent API (devuelve Share/IApplicationLayoutPhase, static ctor → EnsureInitialized) ├── EasyWindowsApplication.Generators/ # Source Generators (EasyBehaviorGenerator EAWIN001/EAWIN002 + ControlActivatorGenerator) +├── Sample/ └── ProjectTemplates/ ├── EasyWinApp/ # Plantilla `dotnet new easywinapp` └── SimpleEasyWinApp/ # Plantilla `dotnet new simpleeasywinapp` diff --git a/src/EasyWindowsApplication.Generators/CulturesGenerator.cs b/src/EasyWindowsApplication.Generators/CulturesGenerator.cs new file mode 100644 index 0000000..a5eca25 --- /dev/null +++ b/src/EasyWindowsApplication.Generators/CulturesGenerator.cs @@ -0,0 +1,106 @@ +using Microsoft.CodeAnalysis; +using System.Text; + +namespace EasyWindowsApplication.Generators; + +[Generator(LanguageNames.CSharp)] +public sealed class CulturesGenerator : IIncrementalGenerator +{ + private const string LibraryAssemblyName = "EasyWindowsApplication"; + + private static readonly string[,] Cultures = new[,] + { + { "En", "en" }, + { "EnUS", "en-US" }, + { "EnGB", "en-GB" }, + { "EnCA", "en-CA" }, + { "EnAU", "en-AU" }, + { "EnIN", "en-IN" }, + { "Es", "es" }, + { "EsES", "es-ES" }, + { "EsMX", "es-MX" }, + { "EsAR", "es-AR" }, + { "EsCO", "es-CO" }, + { "EsCL", "es-CL" }, + { "Fr", "fr" }, + { "FrFR", "fr-FR" }, + { "FrCA", "fr-CA" }, + { "De", "de" }, + { "DeDE", "de-DE" }, + { "DeAT", "de-AT" }, + { "DeCH", "de-CH" }, + { "It", "it" }, + { "ItIT", "it-IT" }, + { "ItCH", "it-CH" }, + { "Pt", "pt" }, + { "PtBR", "pt-BR" }, + { "PtPT", "pt-PT" }, + { "Nl", "nl" }, + { "NlNL", "nl-NL" }, + { "NlBE", "nl-BE" }, + { "Pl", "pl" }, + { "PlPL", "pl-PL" }, + { "Ru", "ru" }, + { "RuRU", "ru-RU" }, + { "UkUA", "uk-UA" }, + { "TrTR", "tr-TR" }, + { "SvSE", "sv-SE" }, + { "NbNO", "nb-NO" }, + { "DaDK", "da-DK" }, + { "FiFI", "fi-FI" }, + { "CsCZ", "cs-CZ" }, + { "HuHU", "hu-HU" }, + { "ElGR", "el-GR" }, + { "HeIL", "he-IL" }, + { "ThTH", "th-TH" }, + { "ViVN", "vi-VN" }, + { "IdID", "id-ID" }, + { "MsMY", "ms-MY" }, + { "RoRO", "ro-RO" }, + { "BgBG", "bg-BG" }, + { "HrHR", "hr-HR" }, + { "SkSK", "sk-SK" }, + { "CaES", "ca-ES" }, + { "Zh", "zh" }, + { "ZhCN", "zh-CN" }, + { "ZhTW", "zh-TW" }, + { "ZhHK", "zh-HK" }, + { "Ja", "ja" }, + { "JaJP", "ja-JP" }, + { "KoKR", "ko-KR" }, + { "HiIN", "hi-IN" }, + { "Ar", "ar" }, + { "ArSA", "ar-SA" }, + { "ArEG", "ar-EG" }, + }; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterSourceOutput( + context.CompilationProvider.Select(static (compilation, _) => + compilation.AssemblyName == LibraryAssemblyName), + static (spc, isLibrary) => + { + if (isLibrary) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("using System.Globalization;"); + sb.AppendLine(); + sb.AppendLine("namespace EasyWindowsApplication.Share;"); + sb.AppendLine(); + sb.AppendLine("public static class Cultures"); + sb.AppendLine("{"); + for (int i = 0; i < Cultures.GetLength(0); i++) + { + sb.AppendLine($" public static readonly CultureInfo CultureInfo{Cultures[i, 0]} = CultureInfo.GetCultureInfo(\"{Cultures[i, 1]}\");"); + } + sb.AppendLine("}"); + spc.AddSource("Cultures.g.cs", sb.ToString()); + } + + spc.AddSource("CulturesUsings.g.cs", + "// \nglobal using static EasyWindowsApplication.Share.Cultures;\n"); + }); + } +} diff --git a/src/EasyWindowsApplication.Generators/EasyBehaviorGenerator.cs b/src/EasyWindowsApplication.Generators/EasyBehaviorGenerator.cs index c609258..3fcf2bf 100644 --- a/src/EasyWindowsApplication.Generators/EasyBehaviorGenerator.cs +++ b/src/EasyWindowsApplication.Generators/EasyBehaviorGenerator.cs @@ -107,18 +107,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { if (!usesWinApi && !item.IsWindowType) continue; var propName = SanitizeName(item.Name); - if (item.IsWindowType) - { - sb.AppendLine($" public {item.TypeSymbol} {propName}"); - sb.AppendLine($" => global::EasyWindowsApplication.Share.Infrastructure.ControlAccess"); - sb.AppendLine($" .GetWindow<{item.TypeSymbol}>(\"{item.Name}\");"); - } - else - { - sb.AppendLine($" public {item.TypeSymbol} {propName}"); - sb.AppendLine($" => global::EasyWindowsApplication.Share.Infrastructure.ControlAccess"); - sb.AppendLine($" .Get<{item.TypeSymbol}>(\"{item.Name}\");"); - } + var lookup = item.IsWindowType + ? $"GetWindow<{item.TypeSymbol}>(\"{item.Name}\")" + : item.IsSurfaceType + ? $"GetSurface<{item.TypeSymbol}>(\"{item.Name}\")" + : $"Get<{item.TypeSymbol}>(\"{item.Name}\")"; + sb.AppendLine($" public {item.TypeSymbol} {propName}"); + sb.AppendLine($" => global::EasyWindowsApplication.Share.Infrastructure.ControlAccess"); + sb.AppendLine($" .{lookup};"); sb.AppendLine(); } @@ -147,18 +143,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { if (!usesWinApi && !item.IsWindowType) continue; var propName = SanitizeName(item.Name); - if (item.IsWindowType) - { - sb.AppendLine($" internal static {item.TypeSymbol} {propName}"); - sb.AppendLine($" => global::EasyWindowsApplication.Share.Infrastructure.ControlAccess"); - sb.AppendLine($" .GetWindow<{item.TypeSymbol}>(\"{item.Name}\");"); - } - else - { - sb.AppendLine($" internal static {item.TypeSymbol} {propName}"); - sb.AppendLine($" => global::EasyWindowsApplication.Share.Infrastructure.ControlAccess"); - sb.AppendLine($" .Get<{item.TypeSymbol}>(\"{item.Name}\");"); - } + var lookup = item.IsWindowType + ? $"GetWindow<{item.TypeSymbol}>(\"{item.Name}\")" + : item.IsSurfaceType + ? $"GetSurface<{item.TypeSymbol}>(\"{item.Name}\")" + : $"Get<{item.TypeSymbol}>(\"{item.Name}\")"; + sb.AppendLine($" internal static {item.TypeSymbol} {propName}"); + sb.AppendLine($" => global::EasyWindowsApplication.Share.Infrastructure.ControlAccess"); + sb.AppendLine($" .{lookup};"); sb.AppendLine(); } @@ -222,28 +214,90 @@ private static bool IsNameCandidate(SyntaxNode node) { var typeArg = generic.TypeArgumentList.Arguments[0]; var typeSymbol = context.SemanticModel.GetTypeInfo(typeArg).Type; - if (typeSymbol == null) return null; + if (typeSymbol is null) return null; return new NamedInfo( name, typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), isWindowType: false, + isSurfaceType: IsSurfaceType(context, typeSymbol), location); } if (parentMa.Name.Identifier.Text == "Window") { - return new NamedInfo(name, "global::EasyWindowsApplication.Share.IWindow", isWindowType: true, location); + return new NamedInfo(name, "global::EasyWindowsApplication.Share.IWindow", isWindowType: true, isSurfaceType: false, location); + } + + if (parentMa.Name is GenericNameSyntax altGeneric + && altGeneric.Identifier.Text == "AlternativeWindow" + && altGeneric.TypeArgumentList.Arguments.Count == 1) + { + var typeArg = altGeneric.TypeArgumentList.Arguments[0]; + var typeSymbol = context.SemanticModel.GetTypeInfo(typeArg).Type; + if (typeSymbol is null) return null; + + var compilation = context.SemanticModel.Compilation; + var iBaseWindow = compilation.GetTypeByMetadataName( + "EasyWindowsApplication.Share.IBaseWindow"); + if (iBaseWindow is not null && Implements(typeSymbol, iBaseWindow)) + { + return new NamedInfo( + name, + typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + isWindowType: true, + isSurfaceType: false, + location); + } + if (IsSurfaceType(context, typeSymbol)) + { + return new NamedInfo( + name, + typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + isWindowType: false, + isSurfaceType: true, + location); + } + return null; } if (parentMa.Name.Identifier.Text == "AlternativeWindow") { - return new NamedInfo(name, "global::EasyWindowsApplication.Share.IAlternativeWindow", isWindowType: true, location); + return new NamedInfo(name, "global::EasyWindowsApplication.Share.IAlternativeWindow", isWindowType: true, isSurfaceType: false, location); } return null; } + private static bool IsSurfaceType(GeneratorSyntaxContext context, ITypeSymbol typeSymbol) + { + var iControl = context.SemanticModel.Compilation.GetTypeByMetadataName( + "EasyWindowsApplication.Win32ControlsModule.Frontend.IControl"); + if (iControl is not null && Implements(typeSymbol, iControl)) + return false; + + var iViewSurface = context.SemanticModel.Compilation.GetTypeByMetadataName( + "EasyWindowsApplication.Share.IViewSurface"); + if (iViewSurface is null) + return false; + + return Implements(typeSymbol, iViewSurface); + } + + private static bool Implements(ITypeSymbol typeSymbol, ITypeSymbol iface) + { + if (typeSymbol.Equals(iface, SymbolEqualityComparer.Default)) + return true; + + foreach (var implemented in typeSymbol.AllInterfaces) + { + if (implemented.Equals(iface, SymbolEqualityComparer.Default)) + return true; + } + + return false; + } + private static bool IsBehaviorCandidate(SyntaxNode node) { if (node is not InvocationExpressionSyntax inv) @@ -386,18 +440,19 @@ private static string SanitizeName(string name) return sb.ToString(); } - private readonly struct NamedInfo(string name, string typeSymbol, bool isWindowType, Location location) + private readonly struct NamedInfo(string name, string typeSymbol, bool isWindowType, bool isSurfaceType, Location location) { public readonly string Name = name; public readonly string TypeSymbol = typeSymbol; public readonly bool IsWindowType = isWindowType; + public readonly bool IsSurfaceType = isSurfaceType; public readonly Location Location = location; public override bool Equals(object obj) => - obj is NamedInfo other && Name == other.Name && TypeSymbol == other.TypeSymbol && IsWindowType == other.IsWindowType; + obj is NamedInfo other && Name == other.Name && TypeSymbol == other.TypeSymbol && IsWindowType == other.IsWindowType && IsSurfaceType == other.IsSurfaceType; public override int GetHashCode() => - (Name?.GetHashCode() ?? 0) ^ (TypeSymbol?.GetHashCode() ?? 0) ^ IsWindowType.GetHashCode(); + (Name?.GetHashCode() ?? 0) ^ (TypeSymbol?.GetHashCode() ?? 0) ^ IsWindowType.GetHashCode() ^ IsSurfaceType.GetHashCode(); } private sealed class EnclosingTypeInfo(string ns, string className) diff --git a/src/EasyWindowsApplication.Generators/FilePatternsGenerator.cs b/src/EasyWindowsApplication.Generators/FilePatternsGenerator.cs new file mode 100644 index 0000000..a83198f --- /dev/null +++ b/src/EasyWindowsApplication.Generators/FilePatternsGenerator.cs @@ -0,0 +1,110 @@ +using Microsoft.CodeAnalysis; +using System.Text; + +namespace EasyWindowsApplication.Generators; + +[Generator(LanguageNames.CSharp)] +public sealed class FilePatternsGenerator : IIncrementalGenerator +{ + private const string LibraryAssemblyName = "EasyWindowsApplication"; + + // Tabla fuente: Token, Descripción, Patrones. + // Basada en mapas de extensiones de .NET y extensiones documentadas de Windows. + // Extensible sin cambiar la API: añadir filas emite tokens nuevos. + private static readonly (string Token, string Description, string[] Patterns)[] Patterns = + [ + ("PngFile", "Imagen PNG", ["*.png"]), + ("JpgFile", "Imagen JPEG", ["*.jpg", "*.jpeg", "*.jfif"]), + ("GifFile", "Imagen GIF", ["*.gif"]), + ("BmpFile", "Imagen BMP", ["*.bmp"]), + ("WebpFile", "Imagen WebP", ["*.webp"]), + ("TiffFile", "Imagen TIFF", ["*.tiff", "*.tif"]), + ("IcoFile", "Icono", ["*.ico"]), + ("SvgFile", "SVG", ["*.svg"]), + ("AllImageFile", "Imágenes", ["*.png", "*.jpg", "*.jpeg", "*.jfif", "*.gif", "*.bmp", "*.webp", "*.tiff", "*.tif", "*.ico", "*.svg"]), + ("Mp4File", "Vídeo MP4", ["*.mp4", "*.m4v"]), + ("MkvFile", "Vídeo MKV", ["*.mkv"]), + ("MovFile", "Vídeo QuickTime", ["*.mov"]), + ("AviFile", "Vídeo AVI", ["*.avi"]), + ("WmvFile", "Vídeo WMV", ["*.wmv"]), + ("WebmFile", "Vídeo WebM", ["*.webm"]), + ("AllVideoFile", "Vídeos", ["*.mp4", "*.m4v", "*.mkv", "*.mov", "*.avi", "*.wmv", "*.webm", "*.mpg", "*.mpeg", "*.3gp", "*.ogv"]), + ("Mp3File", "Audio MP3", ["*.mp3"]), + ("WavFile", "Audio WAV", ["*.wav"]), + ("FlacFile", "Audio FLAC", ["*.flac"]), + ("AacFile", "Audio AAC", ["*.aac", "*.m4a"]), + ("OggFile", "Audio Ogg", ["*.ogg", "*.oga"]), + ("AllAudioFile", "Audio", ["*.mp3", "*.wav", "*.wma", "*.flac", "*.aac", "*.m4a", "*.ogg", "*.oga", "*.opus", "*.mid"]), + ("PdfFile", "PDF", ["*.pdf"]), + ("TxtFile", "Texto", ["*.txt"]), + ("RtfFile", "RTF", ["*.rtf"]), + ("CsvFile", "CSV", ["*.csv"]), + ("MarkdownFile", "Markdown", ["*.md", "*.markdown"]), + ("JsonFile", "JSON", ["*.json"]), + ("XmlFile", "XML", ["*.xml", "*.xsl", "*.xslt"]), + ("DocFile", "Word", ["*.doc", "*.docx"]), + ("XlsFile", "Excel", ["*.xls", "*.xlsx", "*.xlsm"]), + ("PptFile", "PowerPoint", ["*.ppt", "*.pptx"]), + ("OdtFile", "OpenDocument", ["*.odt", "*.ods", "*.odp"]), + ("EpubFile", "EPUB", ["*.epub"]), + ("AllDocumentFile", "Documentos", ["*.pdf", "*.doc", "*.docx", "*.xls", "*.xlsx", "*.xlsm", "*.ppt", "*.pptx", "*.odt", "*.ods", "*.odp", "*.rtf", "*.txt", "*.csv", "*.epub"]), + ("CsFile", "C#", ["*.cs"]), + ("VbFile", "VB", ["*.vb"]), + ("CppFile", "C/C++", ["*.c", "*.h", "*.cpp", "*.hpp", "*.cc"]), + ("JsFile", "JavaScript", ["*.js", "*.mjs", "*.cjs"]), + ("TsFile", "TypeScript", ["*.ts", "*.tsx"]), + ("HtmlFile", "HTML/CSS", ["*.html", "*.htm", "*.css", "*.scss"]), + ("PyFile", "Python", ["*.py"]), + ("JavaFile", "Java", ["*.java"]), + ("SqlFile", "SQL", ["*.sql"]), + ("MdFile", "Markdown", ["*.md"]), + ("AllCodeFile", "Código fuente", ["*.cs", "*.vb", "*.c", "*.h", "*.cpp", "*.hpp", "*.cc", "*.js", "*.mjs", "*.cjs", "*.ts", "*.tsx", "*.html", "*.htm", "*.css", "*.scss", "*.py", "*.java", "*.sql", "*.sh"]), + ("ZipFile", "Comprimido", ["*.zip"]), + ("RarFile", "Compresión RAR", ["*.rar"]), + ("SevenZipFile", "7-Zip", ["*.7z"]), + ("TarFile", "TAR", ["*.tar", "*.tar.gz", "*.tar.bz2"]), + ("GzFile", "GZip", ["*.gz"]), + ("AllArchiveFile", "Archivos comprimidos", ["*.zip", "*.rar", "*.7z", "*.tar", "*.gz", "*.bz2", "*.xz", "*.iso"]), + ("ExeFile", "Ejecutables Windows", ["*.exe", "*.dll"]), + ("BatFile", "Scripts Windows", ["*.bat", "*.cmd", "*.ps1"]), + ("MsiFile", "MSI", ["*.msi"]), + ("TtfFile", "Fuente TrueType", ["*.ttf"]), + ("OtfFile", "Fuente OpenType", ["*.otf"]), + ("WoffFile", "Fuente WOFF", ["*.woff", "*.woff2"]), + ("ProjFile", "Solución/.NET", ["*.csproj", "*.sln", "*.fsproj", "*.vbproj", "*.props", "*.targets"]), + ("NupkgFile", "Paquete NuGet", ["*.nupkg"]), + ("AllFile", "Todos los archivos", ["*.*"]), + ]; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterSourceOutput( + context.CompilationProvider.Select(static (compilation, _) => + compilation.AssemblyName == LibraryAssemblyName), + static (spc, isLibrary) => + { + if (isLibrary) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine(); + sb.AppendLine("namespace EasyWindowsApplication.Share;"); + sb.AppendLine(); + sb.AppendLine("public static class FilePatterns"); + sb.AppendLine("{"); + foreach (var (token, description, patterns) in Patterns) + { + var array = string.Join(", ", System.Linq.Enumerable.Select(patterns, p => $"\"{p}\"")); + sb.AppendLine($" public static readonly FilePattern {token} = new(\"{Escape(description)}\", new[] {{ {array} }});"); + } + sb.AppendLine("}"); + spc.AddSource("FilePatterns.g.cs", sb.ToString()); + } + + spc.AddSource("FilePatternsUsings.g.cs", + "// \nglobal using static EasyWindowsApplication.Share.FilePatterns;\n"); + }); + } + + private static string Escape(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\""); +} diff --git a/src/EasyWindowsApplication/Common/ControlActivatorRegistry.cs b/src/EasyWindowsApplication/Common/ControlActivatorRegistry.cs index 66aaf63..7f9f8b6 100644 --- a/src/EasyWindowsApplication/Common/ControlActivatorRegistry.cs +++ b/src/EasyWindowsApplication/Common/ControlActivatorRegistry.cs @@ -73,7 +73,7 @@ internal bool TryGetFactoryForControl(IControl control, out INativeHandleFactory #pragma warning restore IL2075 // fallback por base types var bt = t.BaseType; - while (bt != null && bt != typeof(object)) + while (bt is not null && bt != typeof(object)) { if (_handleFactories.TryGetValue(bt, out factory)) return true; diff --git a/src/EasyWindowsApplication/Core/AppBehaviorBuilderImpl.cs b/src/EasyWindowsApplication/Core/AppBehaviorBuilderImpl.cs new file mode 100644 index 0000000..6559acd --- /dev/null +++ b/src/EasyWindowsApplication/Core/AppBehaviorBuilderImpl.cs @@ -0,0 +1,68 @@ +using EasyWindowsApplication.Share; + +namespace EasyWindowsApplication.Core; + +internal sealed class AppBehaviorBuilderImpl : IAppBehavior +{ + private readonly List _launched = new(); + private readonly List> _launchedWithSelf = new(); + private readonly List> _terminating = new(); + private readonly List _terminated = new(); + + internal nint MainHwnd { get; set; } + + public IAppBehavior OnLaunched(Action handler) + { + _launched.Add(handler); + return this; + } + + public IAppBehavior OnLaunched(Action handler) + { + _launchedWithSelf.Add(handler); + return this; + } + + public IAppBehavior OnTerminating(Action handler) + { + _terminating.Add(handler); + return this; + } + + public IAppBehavior OnTerminated(Action handler) + { + _terminated.Add(handler); + return this; + } + + public IAppBehavior TaskbarButtonVisibility(bool visible) + { + if (MainHwnd != 0) + TaskbarList.SetVisible(MainHwnd, visible); + return this; + } + + public void Terminate() + { + if (MainHwnd != 0) + Win32.PostMessageW(MainHwnd, WM.CLOSE, 0, 0); + } + + internal void RaiseLaunched() + { + foreach (var handler in _launched.ToArray()) handler(); + foreach (var handler in _launchedWithSelf.ToArray()) handler(this); + } + + internal bool RaiseTerminating() + { + var args = new CancelEventArgs(); + foreach (var handler in _terminating.ToArray()) handler(args); + return args.Cancel; + } + + internal void RaiseTerminated() + { + foreach (var handler in _terminated.ToArray()) handler(); + } +} diff --git a/src/EasyWindowsApplication/Core/Application.cs b/src/EasyWindowsApplication/Core/Application.cs index c1b4079..5fd0d84 100644 --- a/src/EasyWindowsApplication/Core/Application.cs +++ b/src/EasyWindowsApplication/Core/Application.cs @@ -2,7 +2,6 @@ using EasyWindowsApplication.Common; using EasyWindowsApplication.Share; using EasyWindowsApplication.Core.LayoutEngine; -using EasyWindowsApplication.Core.Windowing; using EasyWindowsApplication.Share.Infrastructure; namespace EasyWindowsApplication.Core; @@ -15,10 +14,13 @@ internal sealed class Application : internal ResourcesDictionaryImpl ResourcesDictionary { get; } = new(); internal BehaviorBuilderImpl BehaviorBuilder { get; } = new(); - private readonly List _windows = new(); + private readonly List _windows = []; private MasterRouter _router = null!; private readonly HandleRegistry _registry = new(); private Action? _pendingBehavior; + private nint _mainHwnd; + private SystemTray.SystemTrayBroker? _trayBroker; + private readonly List _pendingMenuSurfaces = []; public IApplicationLayoutPhase Resources(Action configure) { @@ -51,10 +53,25 @@ internal void AddWindow(WindowModel window) } public void Initialize() + { + Threading.UiApartment.RunStaOrCurrent(RunPipeline); + } + + private void RunPipeline() { // 1. Registrar defaults PRIMERO (antes de cualquier control o HFONT) try { UiDefaultsProvider.Set(new Win32ControlsModule.Backend.Win32UiDefaults()); } catch { } + // Cultura explícita (Resources) o automática del OS (no tocar nada) + var culture = ResourcesDictionary.SettingsContext.SelectedCulture; + if (culture is not null) + { + System.Globalization.CultureInfo.CurrentCulture = culture; + System.Globalization.CultureInfo.CurrentUICulture = culture; + System.Globalization.CultureInfo.DefaultThreadCurrentCulture = culture; + System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = culture; + } + var icc = new INITCOMMONCONTROLSEX { dwSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf(), @@ -78,19 +95,44 @@ public void Initialize() RegisterMain(window); } + _router.MainHwnd = _mainHwnd; + if (_pendingBehavior is not null) { BehaviorBuilder.Registry = _registry; + BehaviorBuilder.MainHwnd = _mainHwnd; ControlAccess.SetController(BehaviorBuilder); _pendingBehavior(BehaviorBuilder); + BehaviorBuilder.RaiseLaunched(); } + _router.TerminationGate = () => BehaviorBuilder.RaiseTerminating(); + Procedures.RunMessageLoop(); + + // Teardown determinístico: destruir las secundarias vivas antes del + // epílogo. DestroyWindow entrega WM_DESTROY síncronamente (sin pump). + // Closing/Closed suprimidos: teardown de app, no cierre de ventana — + // ningún callback de Behavior debe correr con la UI del usuario destruida. + foreach (var live in _registry.LiveWindows()) + { + if (live is Windowing.AlternativeWindowImpl { Hwnd: not 0 } alt) + { + alt.MarkTearingDown(); + Win32.DestroyWindow(alt.Hwnd); + } + } + + // Teardown total: entradas restantes (secundarias detachadas, superficies) salen por Remove. + _registry.RemoveAll(); + + BehaviorBuilder.RaiseTerminated(); } private void RegisterMain(WindowModel window) { var hwnd = Core.Windowing.Procedures.CreateMainWindow(_router, window.Title, window.Width, window.Height); + _mainHwnd = hwnd; var win = new Core.Windowing.WindowImpl( hwnd, window.Name, window.Title, window.Width, window.Height, window.Position); @@ -101,29 +143,175 @@ private void RegisterMain(WindowModel window) _router.RegisterWindowBackgroundBrush(hwnd, brush); } - _registry.RegisterWindow(win); + _registry.Attach(EntityKind.Window, win, win.Hwnd, win.Name); + ApplyModelProps(win, window); + ApplyWindowPosition(win, window); // Fase 2: materializar contenido (creación de HWNDs hijos + layout batching sin flicker) win.MaterializeContent(window, _registry, _router); // Si Position == Center, centrar antes de mostrar (multi-monitor correcto) - if (window.Position == WindowPositionOnScreen.Center) + if (window.Position == WindowPositionOnScreen.Center && window.WindowPosition is null) win.Center(); + // Estado inicial (Phase A — #47) + if (window.InitialWindowState != Share.WindowState.Normal) + win.State = window.InitialWindowState; win.Show(); win.RaiseLoaded(); + if (window.SystemTrayConfigure is { } trayConfigure) + SetupSystemTrayIcon(trayConfigure, hwnd); + } + + /// + /// Aplica propiedades del modelo al impl (Phase C — #47). + /// Se usa tras la creación del HWND, antes de materializar contenido. + /// + private static void ApplyModelProps(Windowing.WindowImpl win, WindowModel model) + { + if (model.MinSize is { } ms) win.MinSize = ms; + if (model.MaxSize is { } mx) win.MaxSize = mx; + win.Resizable = model.Resizable; + win.CaptionButtons = model.CaptionButtons; + win.TopMost = model.TopMost; + win.Opacity = model.Opacity; + win.Backdrop = model.Backdrop; + win.ScreenCapture = model.ScreenCapture; + win.ApplyTitleBar(model.TitleBar); + } + + /// Posición unificada (Phase A — #47): coordenada / preset / snap. + private static void ApplyWindowPosition(Windowing.WindowImpl win, WindowModel model) + { + if (model.WindowPosition is null) return; + switch (model.WindowPosition) + { + case Share.PositionAt at: + Win32.SetWindowPos(win.Hwnd, 0, at.X, at.Y, 0, 0, + SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOSIZE); + break; + case Share.PositionPreset preset: + ApplyPositionPreset(win, preset.Preset); + break; + case Share.PositionSnap snap: + ApplyPositionSnap(win, snap.Region); + break; + default: + break; + } + } + + private static void ApplyPositionPreset(Windowing.WindowImpl win, Share.WindowPositionPreset preset) + { + nint mon = Win32.MonitorFromWindow(win.Hwnd, MONITOR.DEFAULTTONEAREST); + if (mon == 0) mon = Win32.MonitorFromWindow(win.Hwnd, MONITOR.DEFAULTTOPRIMARY); + if (mon == 0) return; + MONITORINFO mi = new() { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + if (!Win32.GetMonitorInfoW(mon, ref mi)) return; + Win32.GetWindowRect(win.Hwnd, out RECT wr); + float w = wr.Right - wr.Left, h = wr.Bottom - wr.Top; + float workW = mi.rcWork.Right - mi.rcWork.Left; + float workH = mi.rcWork.Bottom - mi.rcWork.Top; + int x = preset switch + { + Share.WindowPositionPreset.Center => mi.rcWork.Left + (int)((workW - w) / 2), + Share.WindowPositionPreset.TopLeft or Share.WindowPositionPreset.Left or Share.WindowPositionPreset.BottomLeft => mi.rcWork.Left, + Share.WindowPositionPreset.TopRight or Share.WindowPositionPreset.Right or Share.WindowPositionPreset.BottomRight => mi.rcWork.Right - (int)w, + _ => mi.rcWork.Left + (int)((workW - w) / 2), + }; + int y = preset switch + { + Share.WindowPositionPreset.Center => mi.rcWork.Top + (int)((workH - h) / 2), + Share.WindowPositionPreset.TopLeft or Share.WindowPositionPreset.Top or Share.WindowPositionPreset.TopRight => mi.rcWork.Top, + Share.WindowPositionPreset.BottomLeft or Share.WindowPositionPreset.Bottom or Share.WindowPositionPreset.BottomRight => mi.rcWork.Bottom - (int)h, + _ => mi.rcWork.Top + (int)((workH - h) / 2), + }; + Win32.SetWindowPos(win.Hwnd, 0, x, y, 0, 0, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOSIZE); + } + + private static void ApplyPositionSnap(Windowing.WindowImpl win, Share.SnapRegion region) + { + nint mon = Win32.MonitorFromWindow(win.Hwnd, MONITOR.DEFAULTTONEAREST); + if (mon == 0) mon = Win32.MonitorFromWindow(win.Hwnd, MONITOR.DEFAULTTOPRIMARY); + if (mon == 0) return; + MONITORINFO mi = new() { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + if (!Win32.GetMonitorInfoW(mon, ref mi)) return; + float workW = mi.rcWork.Right - mi.rcWork.Left; + float workH = mi.rcWork.Bottom - mi.rcWork.Top; + // Mitades con división entera determinista: Left/Top están atados, Right/Bottom absorben el resto. + float hw = workW / 2, hh = workH / 2; + (int X, int Y, int W, int H) rect = region switch + { + Share.SnapRegion.Left => (mi.rcWork.Left, mi.rcWork.Top, (int)hw, (int)workH), + Share.SnapRegion.Right => (mi.rcWork.Left + (int)hw, mi.rcWork.Top, (int)(workW - hw), (int)workH), + Share.SnapRegion.Top => (mi.rcWork.Left, mi.rcWork.Top, (int)workW, (int)hh), + Share.SnapRegion.Bottom => (mi.rcWork.Left, mi.rcWork.Top + (int)hh, (int)workW, (int)(workH - hh)), + Share.SnapRegion.TopLeft => (mi.rcWork.Left, mi.rcWork.Top, (int)hw, (int)hh), + Share.SnapRegion.TopRight => (mi.rcWork.Left + (int)hw, mi.rcWork.Top, (int)(workW - hw), (int)hh), + Share.SnapRegion.BottomLeft => (mi.rcWork.Left, mi.rcWork.Top + (int)hh, (int)hw, (int)(workH - hh)), + Share.SnapRegion.BottomRight => (mi.rcWork.Left + (int)hw, mi.rcWork.Top + (int)hh, (int)(workW - hw), (int)(workH - hh)), + _ => (mi.rcWork.Left, mi.rcWork.Top, (int)workW, (int)workH), + }; + Win32.SetWindowPos(win.Hwnd, 0, rect.X, rect.Y, rect.W, rect.H, + SWP.NOZORDER | SWP.NOACTIVATE); + } + + private void SetupSystemTrayIcon(Action configure, nint mainHwnd) + { + SystemTray.SystemTrayImpl? tray = null; + var broker = SystemTray.SystemTrayBroker.Create(_router, (trigger, count) => tray?.DispatchTrigger(trigger, count)); + tray = new SystemTray.SystemTrayImpl(broker); + configure(tray); + nint hIcon = Core.Windowing.Procedures.LoadAppIcon(); + if (hIcon == 0) + throw new InvalidOperationException("No hay icono disponible para el SystemTray."); + if (!broker.AddIcon(hIcon)) + throw new InvalidOperationException("Shell_NotifyIconW(NIM_ADD) falló para el SystemTray."); + _registry.Attach(EntityKind.Surface, tray, hwnd: 0, tray.Name); + _trayBroker = broker; + WireMenuSurfaces(); + _router.RegisterHandler(mainHwnd, WM.DESTROY, (w, l) => { broker.Shutdown(); return 0; }); + } + + private void WireMenuSurfaces() + { + if (_trayBroker is null) return; + foreach (var surface in _pendingMenuSurfaces) + { + surface.SetOwner(_trayBroker.Hwnd); + surface.SetIconAnchor(_trayBroker.Hwnd, SystemTray.SystemTrayBroker.IconId); + } + _pendingMenuSurfaces.Clear(); } private void RegisterAlternative(WindowModel window) { + if (window.SurfaceType is not null && typeof(ISystemDialog).IsAssignableFrom(window.SurfaceType)) + { + RegisterAlternativeDialog(window); + return; + } + if (window.SurfaceType is not null && typeof(IMenu).IsAssignableFrom(window.SurfaceType)) + { + var surface = new Menus.MenuSurface(window.Content as ContentModel); + if (!string.IsNullOrEmpty(window.Name)) + surface.Name = window.Name; + surface.Registry = _registry; + surface.EnsureMaterialized(); + _registry.Attach(EntityKind.Surface, surface, hwnd: 0, window.Name); + _pendingMenuSurfaces.Add(surface); + WireMenuSurfaces(); + return; + } var hwnd = Core.Windowing.Procedures.CreateAlternativeWindow(_router, 0, window.Title, window.Width, window.Height); var win = new Core.Windowing.AlternativeWindowImpl(hwnd, 0, window.Name, window.Title, window.Width, window.Height, window.Position); + win.BindForRecreation(window, _registry, _router); if (window.Background.HasValue) { var brush = Win32.CreateSolidBrush(window.Background.Value.ToCOLORREF()); _router.RegisterWindowBackgroundBrush(hwnd, brush); } - _registry.RegisterWindow(win); + _registry.Attach(EntityKind.Window, win, win.Hwnd, win.Name); // Fase 2: materializar contenido para AlternativeWindow también win.MaterializeContent(window, _registry, _router); - if (window.Position == WindowPositionOnScreen.Center) + if (window.Position is WindowPositionOnScreen.Center) { // Center para AlternativeWindow (pre-calcula posición pero no muestra) nint monitor = Win32.MonitorFromWindow(hwnd, MONITOR.DEFAULTTONEAREST); @@ -134,10 +322,10 @@ private void RegisterAlternative(WindowModel window) if (Win32.GetMonitorInfoW(monitor, ref mi)) { Win32.GetWindowRect(hwnd, out RECT wr); - int winW = wr.Right - wr.Left; - int winH = wr.Bottom - wr.Top; - int x = mi.rcWork.Left + ((mi.rcWork.Right - mi.rcWork.Left - winW) / 2); - int y = mi.rcWork.Top + ((mi.rcWork.Bottom - mi.rcWork.Top - winH) / 2); + float winW = wr.Right - wr.Left; + float winH = wr.Bottom - wr.Top; + int x = mi.rcWork.Left + (int)((mi.rcWork.Right - mi.rcWork.Left - winW) / 2); + int y = mi.rcWork.Top + (int)((mi.rcWork.Bottom - mi.rcWork.Top - winH) / 2); Win32.SetWindowPos(hwnd, 0, x, y, 0, 0, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOSIZE); } } @@ -145,4 +333,36 @@ private void RegisterAlternative(WindowModel window) // AlternativeWindow inicia oculta — se muestra explícitamente via GetWindow(name).Show() // No se llama win.Show() ni win.RaiseLoaded() aquí (Loaded se disparará en el Show manual) } + + private void RegisterAlternativeDialog(WindowModel window) + { + Dialogs.DialogSurfaceBase surface = window.SurfaceType == typeof(IOpenFileDialog) + ? new Dialogs.OpenFileDialogImpl() + : window.SurfaceType == typeof(ISaveFileDialog) + ? new Dialogs.SaveFileDialogImpl() + : window.SurfaceType == typeof(ISelectFolderDialog) + ? new Dialogs.SelectFolderDialogImpl() + : window.SurfaceType == typeof(ITaskDialog) + ? new Dialogs.TaskDialogImpl() + : throw new InvalidOperationException($"Tipo de diálogo del sistema no soportado: {window.SurfaceType}."); + + // Owner resuelto en Show() para no depender del orden de declaración en Layout. + surface.OwnerProvider = () => _mainHwnd; + + if (window.ConfigureSurface is null) + { + if (!string.IsNullOrEmpty(window.Name)) + surface.Name = window.Name; + if (!string.IsNullOrEmpty(window.Title)) + surface.Title(window.Title); + } + else + { + window.ConfigureSurface(surface); + } + + if (string.IsNullOrEmpty(surface.Name)) + throw new InvalidOperationException("Los diálogos del sistema requieren Name(...)."); + _registry.Attach(EntityKind.Surface, surface, hwnd: 0, surface.Name); + } } diff --git a/src/EasyWindowsApplication/Core/BehaviorBuilderImpl.cs b/src/EasyWindowsApplication/Core/BehaviorBuilderImpl.cs index 5d2852b..f67519f 100644 --- a/src/EasyWindowsApplication/Core/BehaviorBuilderImpl.cs +++ b/src/EasyWindowsApplication/Core/BehaviorBuilderImpl.cs @@ -3,13 +3,31 @@ namespace EasyWindowsApplication.Core; -internal sealed class BehaviorBuilderImpl : IBehaviorBuilder, ControlAccess.IBehaviorServicesController +internal sealed class BehaviorBuilderImpl : IBehaviorBuilder, ControlAccess.IBehaviorServicesController, ControlAccess.ISurfaceServicesController { + private AppBehaviorBuilderImpl? _appBehavior; + private ISystemTray? _systemTray; + + public IAppBehavior WindowsApplication => _appBehavior ??= new AppBehaviorBuilderImpl() { MainHwnd = MainHwnd }; + + public ISystemTray SystemTray => _systemTray ??= (ISystemTray)Registry!.GetSurface("SystemTray")!; + internal HandleRegistry? Registry { get; set; } + internal nint MainHwnd { get; set; } + + internal void RaiseLaunched() => _appBehavior?.RaiseLaunched(); + + internal bool RaiseTerminating() => _appBehavior?.RaiseTerminating() ?? false; + + internal void RaiseTerminated() => _appBehavior?.RaiseTerminated(); + T ControlAccess.IBehaviorServicesController.Get(string name) => (T)Registry!.GetByName(name)!; T ControlAccess.IBehaviorServicesController.GetWindow(string name) => (T)Registry!.GetWindow(name)!; + + T ControlAccess.ISurfaceServicesController.GetSurface(string name) + => (T)Registry!.GetSurface(name)!; } diff --git a/src/EasyWindowsApplication/Core/Constants.cs b/src/EasyWindowsApplication/Core/Constants.cs index bc1b04e..7dcaa45 100644 --- a/src/EasyWindowsApplication/Core/Constants.cs +++ b/src/EasyWindowsApplication/Core/Constants.cs @@ -27,6 +27,8 @@ internal static class WS_EX internal const uint ACCEPTFILES = 0x00000010; internal const uint LAYOUTRTL = 0x00400000; internal const uint TOPMOST = 0x00000008; + internal const uint LAYERED = 0x00080000; + internal const uint RTLREADING = 0x00002000; } internal static class CS @@ -94,6 +96,11 @@ internal static class WM internal const uint EXITSIZEMOVE = 0x0232; internal const uint SETTINGCHANGE = 0x001A; internal const uint DPICHANGED = 0x02E0; + internal const uint CONTEXTMENU = 0x007B; + internal const uint GETMINMAXINFO = 0x0024; + internal const uint NCCALCSIZE = 0x0083; + internal const uint NCHITTEST = 0x0084; + internal const uint NCACTIVATE = 0x0086; } internal static class SPI @@ -111,6 +118,19 @@ internal static class WMSIZE internal const int MAXHIDE = 4; } + /// ABM_* — mensajes para AppBar/Shell (taskbar). + internal static class ABM + { + internal const uint GETSTATE = 0x00000004; + } + + /// ABS_* — flags de estado devueltos por ABM_GETSTATE. + internal static class ABS + { + internal const uint AUTOHIDE = 0x00000001; + internal const uint ALWAYSONTOP = 0x00000002; + } + internal static class WA { internal const int INACTIVE = 0; @@ -118,6 +138,60 @@ internal static class WA internal const int CLICKACTIVE = 2; } + internal static class SC + { + internal const uint MASK = 0xFFF0; + internal const uint MINIMIZE = 0xF020; + internal const uint MAXIMIZE = 0xF030; + internal const uint RESTORE = 0xF120; + internal const uint KEYMENU = 0xF100; + } + + /// DWMWINDOWATTRIBUTE para Win10 1809+ (SystemBackdropType). + internal static class DWMWA + { + internal const int SYSTEMBACKDROP_TYPE = 38; // DWMWA_SYSTEMBACKDROP_TYPE + } + + /// DWM_SYSTEMBACKDROP_TYPE valores. + internal static class DWMSBT + { + internal const int AUTO = 0; + internal const int NONE = 1; + internal const int MAINWINDOW = 2; // Mica + internal const int TRANSIENTWINDOW = 3; // Acrylic + internal const int TABBEDWINDOW = 4; // Mica Alt + } + + internal static class HT + { + internal const int ERROR = -2; + internal const int TRANSPARENT = -1; + internal const int NOWHERE = 0; + internal const int CLIENT = 1; + internal const int CAPTION = 2; + internal const int SYSMENU = 3; + internal const int GROWBOX = 4; + internal const int SIZE = 4; + internal const int MENU = 5; + internal const int HSCROLL = 6; + internal const int VSCROLL = 7; + internal const int MINBUTTON = 8; + internal const int MAXBUTTON = 9; + internal const int LEFT = 10; + internal const int RIGHT = 11; + internal const int TOP = 12; + internal const int TOPLEFT = 13; + internal const int TOPRIGHT = 14; + internal const int BOTTOM = 15; + internal const int BOTTOMLEFT = 16; + internal const int BOTTOMRIGHT = 17; + internal const int BORDER = 18; + internal const int OBJECT = 19; + internal const int CLOSE = 20; + internal const int HELP = 21; + } + internal static class MONITOR { internal const uint DEFAULTTONULL = 0x00000000; @@ -225,3 +299,98 @@ internal static class SWP internal const uint NOOWNERZORDER = 0x0200; internal const uint NOSENDCHANGING = 0x0400; } + + internal static class WDA + { + internal const uint NONE = 0x00000000; + internal const uint MONITOR = 0x00000001; + internal const uint EXCLUDEFROMCAPTURE = 0x00000011; + } + + internal static class LWA + { + internal const uint COLORKEY = 0x00000001; + internal const uint ALPHA = 0x00000002; + } + + internal static class MF + { + internal const uint STRING = 0x00000000; + internal const uint SEPARATOR = 0x00000800; + internal const uint POPUP = 0x00000010; + internal const uint GRAYED = 0x00000001; + internal const uint DISABLED = 0x00000002; + internal const uint CHECKED = 0x00000008; + internal const uint UNCHECKED = 0x00000000; + internal const uint BYCOMMAND = 0x00000000; + internal const uint BYPOSITION = 0x00000400; + } + + internal static class TPM + { + internal const uint LEFTALIGN = 0x0000; + internal const uint CENTERALIGN = 0x0004; + internal const uint RIGHTALIGN = 0x0008; + internal const uint TOPALIGN = 0x0000; + internal const uint VCENTERALIGN = 0x0010; + internal const uint BOTTOMALIGN = 0x0020; + internal const uint NONOTIFY = 0x0080; + internal const uint RETURNCMD = 0x0100; + } + + internal static class NIM + { + internal const uint ADD = 0x00000000; + internal const uint MODIFY = 0x00000001; + internal const uint DELETE = 0x00000002; + internal const uint SETFOCUS = 0x00000003; + internal const uint SETVERSION = 0x00000004; + } + + internal static class NIF + { + internal const uint MESSAGE = 0x00000001; + internal const uint ICON = 0x00000002; + internal const uint TIP = 0x00000004; + internal const uint STATE = 0x00000008; + internal const uint INFO = 0x00000010; + internal const uint GUID = 0x00000020; + internal const uint SHOWTIP = 0x00000080; + } + + internal static class NIIF + { + internal const uint NONE = 0x00000000; + internal const uint INFO = 0x00000001; + internal const uint WARNING = 0x00000002; + internal const uint ERROR = 0x00000003; + internal const uint USER = 0x00000004; + internal const uint NOSOUND = 0x00000010; + internal const uint LARGE_ICON = 0x00000020; + internal const uint RESPECT_QUIET_TIME = 0x00000080; + } + + internal static class NIN + { + internal const uint SELECT = 0x0400; + internal const uint KEYSELECT = 0x0401; + internal const uint BALLOONSHOW = 0x0402; + internal const uint BALLOONHIDE = 0x0403; + internal const uint BALLOONTIMEOUT = 0x0404; + internal const uint BALLOONUSERCLICK = 0x0405; + internal const uint POPUPOPEN = 0x0406; + internal const uint POPUPCLOSE = 0x0407; + } + + internal static class NOTIFYICON + { + internal const uint VERSION_4 = 4; + } + + internal static class VK + { + internal const int SHIFT = 0x10; + internal const int CONTROL = 0x11; + internal const int MENU = 0x12; + internal const int APPS = 0x5D; + } diff --git a/src/EasyWindowsApplication/Core/ControlBase.cs b/src/EasyWindowsApplication/Core/ControlBase.cs index 393144e..c89ba9d 100644 --- a/src/EasyWindowsApplication/Core/ControlBase.cs +++ b/src/EasyWindowsApplication/Core/ControlBase.cs @@ -2,7 +2,9 @@ using System.Runtime.InteropServices; using EasyWindowsApplication.Core.LayoutEngine; +using EasyWindowsApplication.Core.Surfaces; using EasyWindowsApplication.Share; +using EasyWindowsApplication.Share.Input; using EasyWindowsApplication.Win32ControlsModule.Backend; using EasyWindowsApplication.Win32ControlsModule.Frontend; using static EasyWindowsApplication.Core.Win32; @@ -10,37 +12,27 @@ namespace EasyWindowsApplication.Core; [EditorBrowsable(EditorBrowsableState.Never)] -public abstract class ControlBase : IControl, IClickEventSource, ILayoutable, IDockable +public abstract class ControlBase : IControl, IInputSurface, IInputFeed, ILayoutable, IDockable { internal ControlBase() { } + private readonly SurfaceInputHub _hub = new(); + void IInputFeed.FeedTrigger(Type trigger, int count) => _hub.Fire(trigger, count); + public nint Hwnd { get; internal set; } public string Name { get; set; } = ""; - // IBaseWindow lifecycle events (requeridos por IBaseWindow pero no usados en controles) + // IBaseWindow lifecycle events (requeridos por IBaseWindow vía IControl) public event EventHandler? Loaded { add { } remove { } } - public event EventHandler? Closing { add { } remove { } } - public event EventHandler? Closed { add { } remove { } } public event EventHandler? Activated { add { } remove { } } public event EventHandler? Deactivated { add { } remove { } } - private float _x, _y, _w, _h; - - internal event Action? InternalClick; - - void IClickEventSource.RaiseClickInternal() => InternalClick?.Invoke(); - void IClickEventSource.AddClickHandler(Action handler) => InternalClick += handler; - - public event Action? Clicked - { - add => InternalClick += value; - remove => InternalClick -= value; - } + // Behavior-first lifecycle — no-op for controls (controls don't raise lifecycle) + public void OnLoaded(Action handler) { } + public void OnActivated(Action handler) { } + public void OnDeactivated(Action handler) { } - public void OnClick(Action handler) - { - InternalClick += handler; - } + private float _x, _y, _w, _h; public nint OnMessage(uint msg, Win32MessageHandler handler) { @@ -216,7 +208,7 @@ private static nint GetFallbackFont() try { return ControlProcedures.GetDefaultFont(); } catch { return GetStockObject(17); } } - protected (int Width, int Height) MeasureText(string text) + protected (float Width, float Height) MeasureText(string text) { if (string.IsNullOrEmpty(text)) return (20, 24); @@ -231,7 +223,7 @@ private static nint GetFallbackFont() return (rect.Right - rect.Left, rect.Bottom - rect.Top); } - protected (int Width, int Height) MeasureTextV2(string text) + protected (float Width, float Height) MeasureTextV2(string text) { if (string.IsNullOrEmpty(text)) return (20, 24); @@ -247,7 +239,7 @@ private static nint GetFallbackFont() return (size.cx, size.cy); } - protected (int Width, int Height) MeasureTextByGlyphs(string text) + protected (float Width, float Height) MeasureTextByGlyphs(string text) { if (string.IsNullOrEmpty(text)) return (20, 24); @@ -309,4 +301,18 @@ private static int MeasureSurrogatePairFallback(nint hdc, string text, int index } return 8; } + + public void OnInputWithSpatialPosition(Action handler) where TTrigger : ISpatialPositionTrigger + => OnInputWithSpatialPosition(handler); + + public void OnInputWithSpatialPosition(Action handler) + where TTrigger : ISpatialPositionTrigger + where TCount : ITapCountSpec + => _hub.AddSpatial(typeof(TTrigger), typeof(TCount), handler); + + public void OnInputWithSpatialPosition(Action handler) + => OnInputWithSpatialPosition(handler); + + public void OnInputWithoutSpatialPosition(Action handler) where TTrigger : IWithoutSpatialPositionTrigger + => _hub.AddNonSpatial(typeof(TTrigger), handler); } diff --git a/src/EasyWindowsApplication/Core/Dialogs/Com/FileDialogCom.cs b/src/EasyWindowsApplication/Core/Dialogs/Com/FileDialogCom.cs new file mode 100644 index 0000000..25f1432 --- /dev/null +++ b/src/EasyWindowsApplication/Core/Dialogs/Com/FileDialogCom.cs @@ -0,0 +1,364 @@ +using System.Runtime.InteropServices; + +namespace EasyWindowsApplication.Core.Dialogs.Com; + +internal enum FileDialogKind +{ + Open, + Save, + Folder +} + +internal sealed record FileDialogFilter(string Name, string Spec); + +// Motor COM unificado (IFileDialog + IFileOpenDialog + IFileSaveDialog). +// Interop manual vtable estilo TaskbarList.cs: AOT-safe, cero dependencias. +// +// Índices vtable absolutos (base 0 desde IUnknown), según shobjidl_core.h: +// IUnknown: 0 QueryInterface, 1 AddRef, 2 Release +// IModalWindow: 3 Show +// IFileDialog: 4 SetFileTypes, 5 SetFileTypeIndex, 6 GetFileTypeIndex, +// 7 Advise, 8 Unadvise, 9 SetOptions, 10 GetOptions, 11 SetDefaultFolder, +// 12 SetFolder, 13 GetFolder, 14 GetCurrentSelection, 15 SetFileName, +// 16 GetFileName, 17 SetTitle, 18 SetOkButtonLabel, 19 SetFileNameLabel, +// 20 GetResult, 21 AddPlace, 22 SetDefaultExtension, 23 Close, +// 24 SetClientGuid, 25 ClearClientData, 26 SetFilter +// IFileOpenDialog: 27 GetResults, 28 GetSelectedItems +// IShellItem (IUnknown): 3 BindToHandler, 4 GetParent, 5 GetDisplayName, +// 6 GetAttributes, 7 Compare +// IShellItemArray (IUnknown): 3 BindToHandler, 4 GetPropertyStore, +// 5 GetPropertyDescriptionList, 6 GetAttributes, 7 GetCount, +// 8 GetItemAt, 9 EnumItems +internal static partial class FileDialogCom +{ + [LibraryImport("ole32.dll")] + private static partial int CoCreateInstance( + ref Guid rclsid, nint pUnkOuter, uint dwClsContext, ref Guid riid, out nint ppv); + + [LibraryImport("ole32.dll")] + private static partial int CoInitializeEx(nint pvReserved, uint dwCoInit); + + [LibraryImport("ole32.dll")] + private static partial void CoUninitialize(); + + private const uint COINIT_APARTMENTTHREADED = 0x2; + private const int S_OK = 0; + private const int S_FALSE_COM = 1; + private const int RPC_E_CHANGED_MODE = unchecked((int)0x80010106); + + [LibraryImport("ole32.dll")] + private static partial void CoTaskMemFree(nint pv); + + [LibraryImport("shell32.dll", StringMarshalling = StringMarshalling.Utf16)] + private static partial int SHCreateItemFromParsingName( + string pszPath, nint pbc, ref Guid riid, out nint ppv); + + [StructLayout(LayoutKind.Sequential)] + private struct COMDLG_FILTERSPEC + { + internal nint pszName; + internal nint pszSpec; + } + + internal static unsafe (List Paths, bool Canceled) ShowFileDialog( + nint owner, + FileDialogKind kind, + IReadOnlyList filters, + bool multiSelect, + string? defaultFolder, + string? defaultFileName, + string? defaultExtension, + string? title, + bool persistLastDirectory = true) + { + Guid clsid = kind == FileDialogKind.Save + ? FileDialogNative.CLSID_FileSaveDialog + : FileDialogNative.CLSID_FileOpenDialog; + Guid iid = kind == FileDialogKind.Save + ? FileDialogNative.IID_IFileSaveDialog + : FileDialogNative.IID_IFileOpenDialog; + + // Los diálogos comunes COM son STA-only. En el diseño actual el pipeline + // corre en el hilo EWA-UI (STA), así que esto es solo una red de seguridad: + // el único modo de llegar aquí sin STA es invocar Show() desde otra hebra + // (p. ej. Task.Run / ThreadPool), caso en el que CoInitializeEx(STA) + // devuelve RPC_E_CHANGED_MODE porque el apartment COM es inmutable. + // S_OK => inicializamos nosotros y debemos CoUninitialize al final; + // S_FALSE => ya estaba inicializado en este mismo hilo (compatible). + int coinit = CoInitializeEx(0, COINIT_APARTMENTTHREADED); + if (coinit == RPC_E_CHANGED_MODE) + throw new InvalidOperationException( + "Un diálogo del sistema debe invocarse desde el hilo de UI (EWA-UI, STA). " + + "El hilo actual está en MTA (CoInitializeEx devolvió RPC_E_CHANGED_MODE). " + + "NO uses Task.Run / ThreadPool / hilos propios alrededor de Show(): " + + "los resultados solo se pueden devolver desde el behavior que abre el diálogo."); + bool ownCom = coinit == S_OK; + try + { + int hr = CoCreateInstance(ref clsid, 0, FileDialogNative.CLSCTX_INPROC_SERVER, ref iid, out nint dialog); + if (hr != 0 || dialog == 0) + throw new InvalidOperationException($"CoCreateInstance del diálogo común falló (HRESULT 0x{hr:X8})."); + try + { + nint* vtable = *(nint**)dialog; + + // Opciones: leer las actuales y OR con las necesarias (no pisar defaults del OS). + uint options = GetOptions(dialog, vtable); + uint wanted = 0; + if (kind == FileDialogKind.Save) + wanted |= FileDialogNative.FOS_OVERWRITEPROMPT; + if (kind == FileDialogKind.Folder) + wanted |= FileDialogNative.FOS_PICKFOLDERS | FileDialogNative.FOS_FORCEFILESYSTEM; + if (kind == FileDialogKind.Open && multiSelect) + wanted |= FileDialogNative.FOS_ALLOWMULTISELECT; + if (wanted != 0) + SetOptions(dialog, vtable, options | wanted); + + // Filtros (no aplican a Folder: SetFileTypes falla con FOS_PICKFOLDERS). + if (kind != FileDialogKind.Folder && filters.Count > 0) + SetFileTypes(dialog, vtable, filters); + + if (!string.IsNullOrEmpty(title)) + SetTitle(dialog, vtable, title); + + if (!string.IsNullOrEmpty(defaultFolder)) + SetDefaultFolder(dialog, vtable, defaultFolder); + + if (kind == FileDialogKind.Save) + { + if (!string.IsNullOrEmpty(defaultFileName)) + SetFileName(dialog, vtable, defaultFileName); + if (!string.IsNullOrEmpty(defaultExtension)) + SetDefaultExtension(dialog, vtable, defaultExtension); + } + + // PersistLastDirectory(false): aislar la persistencia del shell + // generando un ClientGuid único por llamada, de modo que + // DefaultDirectory se respete siempre. + if (!persistLastDirectory) + SetClientGuid(dialog, vtable, Guid.NewGuid()); + + var show = (delegate* unmanaged[Stdcall])vtable[3]; + int showHr = show(dialog, owner); + if (showHr == FileDialogNative.HRESULT_CANCELLED) + return ([], true); + if (showHr != 0) + return ([], true); + + if (kind == FileDialogKind.Open && multiSelect) + return (GetResults(dialog, vtable), false); + + string? single = GetResultPath(dialog, vtable); + return (single is null ? [] : [single], false); + } + finally + { + var release = (delegate* unmanaged[Stdcall])((*(nint**)dialog)[2]); + release(dialog); + } + } + finally + { + if (ownCom) + CoUninitialize(); + } + } + + private static unsafe uint GetOptions(nint dialog, nint* vtable) + { + var getOptions = (delegate* unmanaged[Stdcall])vtable[10]; + uint options = 0; + getOptions(dialog, &options); + return options; + } + + private static unsafe void SetOptions(nint dialog, nint* vtable, uint options) + { + var setOptions = (delegate* unmanaged[Stdcall])vtable[9]; + setOptions(dialog, options); + } + + private static unsafe void SetFileTypes(nint dialog, nint* vtable, IReadOnlyList filters) + { + int count = filters.Count; + nint specsMem = Marshal.AllocHGlobal(sizeof(COMDLG_FILTERSPEC) * count); + var allocated = new List(count * 2); + try + { + var specs = (COMDLG_FILTERSPEC*)specsMem; + for (int i = 0; i < count; i++) + { + nint name = Marshal.StringToCoTaskMemUni(filters[i].Name); + nint spec = Marshal.StringToCoTaskMemUni(filters[i].Spec); + allocated.Add(name); + allocated.Add(spec); + specs[i].pszName = name; + specs[i].pszSpec = spec; + } + var setFileTypes = (delegate* unmanaged[Stdcall])vtable[4]; + setFileTypes(dialog, (uint)count, specsMem); + var setFileTypeIndex = (delegate* unmanaged[Stdcall])vtable[5]; + setFileTypeIndex(dialog, 1); + } + finally + { + foreach (nint p in allocated) + Marshal.FreeCoTaskMem(p); + Marshal.FreeHGlobal(specsMem); + } + } + + private static unsafe void SetTitle(nint dialog, nint* vtable, string title) + { + nint ptr = Marshal.StringToCoTaskMemUni(title); + try + { + var setTitle = (delegate* unmanaged[Stdcall])vtable[17]; + setTitle(dialog, ptr); + } + finally + { + Marshal.FreeCoTaskMem(ptr); + } + } + + private static unsafe void SetFileName(nint dialog, nint* vtable, string fileName) + { + nint ptr = Marshal.StringToCoTaskMemUni(fileName); + try + { + var setFileName = (delegate* unmanaged[Stdcall])vtable[15]; + setFileName(dialog, ptr); + } + finally + { + Marshal.FreeCoTaskMem(ptr); + } + } + + private static unsafe void SetDefaultExtension(nint dialog, nint* vtable, string extension) + { + string ext = extension.TrimStart('.', '*'); + nint ptr = Marshal.StringToCoTaskMemUni(ext); + try + { + var setDefaultExtension = (delegate* unmanaged[Stdcall])vtable[22]; + setDefaultExtension(dialog, ptr); + } + finally + { + Marshal.FreeCoTaskMem(ptr); + } + } + + private static unsafe void SetDefaultFolder(nint dialog, nint* vtable, string path) + { + try + { + Guid iid = FileDialogNative.IID_IShellItem; + int hr = SHCreateItemFromParsingName(path, 0, ref iid, out nint item); + if (hr != 0 || item == 0) + return; + try + { + var setDefaultFolder = (delegate* unmanaged[Stdcall])vtable[11]; + setDefaultFolder(dialog, item); + } + finally + { + var release = (delegate* unmanaged[Stdcall])((*(nint**)item)[2]); + release(item); + } + } + catch + { + // DefaultDirectory inválido: el diálogo abre con la carpeta por defecto del OS. + } + } + + private static unsafe void SetClientGuid(nint dialog, nint* vtable, Guid guid) + { + var setClientGuid = (delegate* unmanaged[Stdcall])vtable[24]; + setClientGuid(dialog, &guid); + } + + private static unsafe string? GetResultPath(nint dialog, nint* vtable) + { + var getResult = (delegate* unmanaged[Stdcall])vtable[20]; + nint item = 0; + if (getResult(dialog, &item) != 0 || item == 0) + return null; + try + { + return GetItemPath(item); + } + finally + { + var release = (delegate* unmanaged[Stdcall])((*(nint**)item)[2]); + release(item); + } + } + + private static unsafe List GetResults(nint dialog, nint* vtable) + { + var result = new List(); + var getResults = (delegate* unmanaged[Stdcall])vtable[27]; + nint array = 0; + if (getResults(dialog, &array) != 0 || array == 0) + { + string? fallback = GetResultPath(dialog, vtable); + if (fallback is not null) + result.Add(fallback); + return result; + } + try + { + nint* arrayVtable = *(nint**)array; + var getCount = (delegate* unmanaged[Stdcall])arrayVtable[7]; + var getItemAt = (delegate* unmanaged[Stdcall])arrayVtable[8]; + uint count = 0; + if (getCount(array, &count) != 0) + return result; + for (uint i = 0; i < count; i++) + { + nint item = 0; + if (getItemAt(array, i, &item) != 0 || item == 0) + continue; + try + { + string? path = GetItemPath(item); + if (path is not null) + result.Add(path); + } + finally + { + var release = (delegate* unmanaged[Stdcall])((*(nint**)item)[2]); + release(item); + } + } + return result; + } + finally + { + var release = (delegate* unmanaged[Stdcall])((*(nint**)array)[2]); + release(array); + } + } + + private static unsafe string? GetItemPath(nint item) + { + nint* itemVtable = *(nint**)item; + var getDisplayName = (delegate* unmanaged[Stdcall])itemVtable[5]; + nint psz = 0; + if (getDisplayName(item, FileDialogNative.SIGDN_FILESYSPATH, &psz) != 0 || psz == 0) + return null; + try + { + return Marshal.PtrToStringUni(psz); + } + finally + { + CoTaskMemFree(psz); + } + } +} diff --git a/src/EasyWindowsApplication/Core/Dialogs/Com/FileDialogNative.cs b/src/EasyWindowsApplication/Core/Dialogs/Com/FileDialogNative.cs new file mode 100644 index 0000000..5d06692 --- /dev/null +++ b/src/EasyWindowsApplication/Core/Dialogs/Com/FileDialogNative.cs @@ -0,0 +1,27 @@ +namespace EasyWindowsApplication.Core.Dialogs.Com; + +// Constantes del motor de diálogos comunes (shobjidl_core.h). +// Se pinan contra la documentación oficial de Microsoft Learn. +internal static class FileDialogNative +{ + // CLSID / IID + internal static readonly Guid CLSID_FileOpenDialog = new("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7"); + internal static readonly Guid CLSID_FileSaveDialog = new("C0B4E2F3-BA21-4773-8DBA-335EC946EB8B"); + internal static readonly Guid IID_IFileOpenDialog = new("D57C7288-D4AD-4768-BE02-9D969532D960"); + internal static readonly Guid IID_IFileSaveDialog = new("84BCCD23-5FDE-4CDB-AEA4-AF64B83D78AB"); + internal static readonly Guid IID_IShellItem = new("43826D1E-E718-42EE-BC55-A1E261C37BFE"); + + internal const uint CLSCTX_INPROC_SERVER = 1; + + // FILEOPENDIALOGOPTIONS (shobjidl_core.h) + internal const uint FOS_OVERWRITEPROMPT = 0x00000002; + internal const uint FOS_PICKFOLDERS = 0x00000020; + internal const uint FOS_FORCEFILESYSTEM = 0x00000040; + internal const uint FOS_ALLOWMULTISELECT = 0x00000200; + + // SIGDN + internal const uint SIGDN_FILESYSPATH = 0x80058000; + + // HRESULT de cancelación (HRESULT_FROM_WIN32(ERROR_CANCELLED)) + internal const int HRESULT_CANCELLED = unchecked((int)0x800704C7); +} diff --git a/src/EasyWindowsApplication/Core/Dialogs/DialogSurfaceBase.cs b/src/EasyWindowsApplication/Core/Dialogs/DialogSurfaceBase.cs new file mode 100644 index 0000000..9bdc0bb --- /dev/null +++ b/src/EasyWindowsApplication/Core/Dialogs/DialogSurfaceBase.cs @@ -0,0 +1,44 @@ +using EasyWindowsApplication.Share; + +namespace EasyWindowsApplication.Core.Dialogs; + +// Impl común de ISystemDialog: fluent Name/Title, props inertes de +// IViewSurface (los diálogos no tienen HWND propio) + OwnerProvider. +// +// Nota: IViewSurface ya declara la propiedad Name, así que los métodos +// fluent Name(string) se implementan explícitamente para evitar CS0102. +internal abstract class DialogSurfaceBase : ISystemDialog +{ + internal Func OwnerProvider { get; set; } = static () => 0; + + internal string DialogTitle { get; set; } = ""; + + public string Name { get; set; } = ""; + public LayoutLength? LayoutWidth { get; set; } + public LayoutLength? LayoutHeight { get; set; } + public LayoutOptions LayoutOptions { get; set; } = new(); + public Thickness Margin { get; set; } + public Thickness Padding { get; set; } + public DockPosition Dock { get; set; } + public int GridRow { get; set; } + public int GridColumn { get; set; } + public int GridRowSpan { get; set; } + public int GridColumnSpan { get; set; } + public Color? BackgroundColor { get; set; } + + ISystemDialog ISystemDialog.Name(string name) + { + Name = name; + return this; + } + + public ISystemDialog Title(string title) + { + DialogTitle = title; + return this; + } + + public void Visibility(bool visible) + { + } +} diff --git a/src/EasyWindowsApplication/Core/Dialogs/FileDialogFiltersBuilder.cs b/src/EasyWindowsApplication/Core/Dialogs/FileDialogFiltersBuilder.cs new file mode 100644 index 0000000..98a7f9b --- /dev/null +++ b/src/EasyWindowsApplication/Core/Dialogs/FileDialogFiltersBuilder.cs @@ -0,0 +1,33 @@ +using EasyWindowsApplication.Core.Dialogs.Com; +using EasyWindowsApplication.Share; + +namespace EasyWindowsApplication.Core.Dialogs; + +// Builder de filtros: acumula grupos Win32 (descripción + patrones). +internal sealed class FileDialogFiltersBuilder : IFileDialogFiltersBuilder +{ + internal readonly List Filters = []; + + public IFileDialogFiltersBuilder Children(Action configure) + { + var children = new FileFilterChildrenBuilder(Filters); + configure(children); + return this; + } +} + +internal sealed class FileFilterChildrenBuilder(List target) : IFileFilterChildrenBuilder +{ + public IFileFilterChildrenBuilder FileFilter(FilePattern preset) + { + target.Add(new FileDialogFilter(preset.Description, preset.Win32Patterns)); + return this; + } + + public IFileFilterChildrenBuilder FileFilter(string description, params FilePattern[] patterns) + { + string spec = string.Join(';', patterns.SelectMany(p => p.Filters)); + target.Add(new FileDialogFilter(description, spec)); + return this; + } +} diff --git a/src/EasyWindowsApplication/Core/Dialogs/OpenFileDialogImpl.cs b/src/EasyWindowsApplication/Core/Dialogs/OpenFileDialogImpl.cs new file mode 100644 index 0000000..aacfa91 --- /dev/null +++ b/src/EasyWindowsApplication/Core/Dialogs/OpenFileDialogImpl.cs @@ -0,0 +1,63 @@ +using EasyWindowsApplication.Core.Dialogs.Com; +using EasyWindowsApplication.Share; + +namespace EasyWindowsApplication.Core.Dialogs; + +internal sealed class OpenFileDialogImpl : DialogSurfaceBase, IOpenFileDialog +{ + internal readonly List FilterList = []; + internal string? DefaultDirectoryValue; + internal bool MultiSelectValue; + + IOpenFileDialog IOpenFileDialog.Name(string name) + { + Name = name; + return this; + } + + IOpenFileDialog IOpenFileDialog.Title(string title) + { + DialogTitle = title; + return this; + } + + public IOpenFileDialog Filters(Action configure) + { + var builder = new FileDialogFiltersBuilder(); + builder.Filters.AddRange(FilterList); + configure(builder); + FilterList.Clear(); + FilterList.AddRange(builder.Filters); + return this; + } + + public IOpenFileDialog Content(Action configure) + { + var builder = new OpenFileDialogContentBuilder(this); + configure(builder); + return this; + } + + public FileOpenResult Show() + { + var (paths, canceled) = FileDialogCom.ShowFileDialog( + OwnerProvider(), FileDialogKind.Open, FilterList, + MultiSelectValue, DefaultDirectoryValue, null, null, DialogTitle); + return new FileOpenResult(canceled, paths); + } +} + +internal sealed class OpenFileDialogContentBuilder(OpenFileDialogImpl target) : IOpenFileDialogContentBuilder +{ + public IOpenFileDialogContentBuilder DefaultDirectory(string path) + { + target.DefaultDirectoryValue = path; + return this; + } + + public IOpenFileDialogContentBuilder MultiSelect(bool value) + { + target.MultiSelectValue = value; + return this; + } +} \ No newline at end of file diff --git a/src/EasyWindowsApplication/Core/Dialogs/SaveFileDialogImpl.cs b/src/EasyWindowsApplication/Core/Dialogs/SaveFileDialogImpl.cs new file mode 100644 index 0000000..0c72307 --- /dev/null +++ b/src/EasyWindowsApplication/Core/Dialogs/SaveFileDialogImpl.cs @@ -0,0 +1,70 @@ +using EasyWindowsApplication.Core.Dialogs.Com; +using EasyWindowsApplication.Share; + +namespace EasyWindowsApplication.Core.Dialogs; + +internal sealed class SaveFileDialogImpl : DialogSurfaceBase, ISaveFileDialog +{ + internal readonly List FilterList = []; + internal string? DefaultDirectoryValue; + internal string? DefaultFileNameValue; + internal string? DefaultExtensionValue; + + ISaveFileDialog ISaveFileDialog.Name(string name) + { + Name = name; + return this; + } + + ISaveFileDialog ISaveFileDialog.Title(string title) + { + DialogTitle = title; + return this; + } + + public ISaveFileDialog Filters(Action configure) + { + var builder = new FileDialogFiltersBuilder(); + builder.Filters.AddRange(FilterList); + configure(builder); + FilterList.Clear(); + FilterList.AddRange(builder.Filters); + return this; + } + + public ISaveFileDialog Content(Action configure) + { + var builder = new SaveFileDialogContentBuilder(this); + configure(builder); + return this; + } + + public FileSaveResult Show() + { + var (paths, canceled) = FileDialogCom.ShowFileDialog( + OwnerProvider(), FileDialogKind.Save, FilterList, + false, DefaultDirectoryValue, DefaultFileNameValue, DefaultExtensionValue, DialogTitle); + return new FileSaveResult(canceled, canceled || paths.Count == 0 ? null : paths[0]); + } +} + +internal sealed class SaveFileDialogContentBuilder(SaveFileDialogImpl target) : ISaveFileDialogContentBuilder +{ + public ISaveFileDialogContentBuilder DefaultDirectory(string path) + { + target.DefaultDirectoryValue = path; + return this; + } + + public ISaveFileDialogContentBuilder DefaultFileName(string fileName) + { + target.DefaultFileNameValue = fileName; + return this; + } + + public ISaveFileDialogContentBuilder DefaultExtension(FilePattern pattern) + { + target.DefaultExtensionValue = pattern.DefaultExtension; + return this; + } +} \ No newline at end of file diff --git a/src/EasyWindowsApplication/Core/Dialogs/SelectFolderDialogImpl.cs b/src/EasyWindowsApplication/Core/Dialogs/SelectFolderDialogImpl.cs new file mode 100644 index 0000000..a04dfa9 --- /dev/null +++ b/src/EasyWindowsApplication/Core/Dialogs/SelectFolderDialogImpl.cs @@ -0,0 +1,52 @@ +using EasyWindowsApplication.Core.Dialogs.Com; +using EasyWindowsApplication.Share; + +namespace EasyWindowsApplication.Core.Dialogs; + +internal sealed class SelectFolderDialogImpl : DialogSurfaceBase, ISelectFolderDialog +{ + internal string? DefaultDirectoryValue; + internal bool PersistLastDirectoryValue = true; // default = true (comportamiento nativo) + + ISelectFolderDialog ISelectFolderDialog.Name(string name) + { + Name = name; + return this; + } + + ISelectFolderDialog ISelectFolderDialog.Title(string title) + { + DialogTitle = title; + return this; + } + + public ISelectFolderDialog Content(Action configure) + { + var builder = new SelectFolderDialogContentBuilder(this); + configure(builder); + return this; + } + + public FolderSelectResult Show() + { + var (paths, canceled) = FileDialogCom.ShowFileDialog( + OwnerProvider(), FileDialogKind.Folder, [], + false, DefaultDirectoryValue, null, null, DialogTitle, PersistLastDirectoryValue); + return new FolderSelectResult(canceled, canceled || paths.Count == 0 ? null : paths[0]); + } +} + +internal sealed class SelectFolderDialogContentBuilder(SelectFolderDialogImpl target) : ISelectFolderDialogContentBuilder +{ + public ISelectFolderDialogContentBuilder DefaultDirectory(string path) + { + target.DefaultDirectoryValue = path; + return this; + } + + public ISelectFolderDialogContentBuilder PersistLastDirectory(bool persist) + { + target.PersistLastDirectoryValue = persist; + return this; + } +} \ No newline at end of file diff --git a/src/EasyWindowsApplication/Core/Dialogs/TaskDialogImpl.cs b/src/EasyWindowsApplication/Core/Dialogs/TaskDialogImpl.cs new file mode 100644 index 0000000..f115c78 --- /dev/null +++ b/src/EasyWindowsApplication/Core/Dialogs/TaskDialogImpl.cs @@ -0,0 +1,82 @@ +using EasyWindowsApplication.Share; + +namespace EasyWindowsApplication.Core.Dialogs; + +internal sealed class TaskDialogImpl : DialogSurfaceBase, ITaskDialog +{ + internal string? TextValue; // modo mínimo + internal string? PrimaryTextValue; // builder + internal string? SecondaryTextValue; // builder + internal TaskDialogIcon NotificationIconValue = TaskDialogIcon.None; + internal TaskDialogChoices ChoicesValue = TaskDialogChoices.Ok; + internal TaskDialogChoice DefaultChoiceValue; + + ITaskDialog ITaskDialog.Name(string name) + { + Name = name; + return this; + } + + ITaskDialog ITaskDialog.Title(string title) + { + DialogTitle = title; + return this; + } + + public ITaskDialog Text(string text) + { + TextValue = text; + return this; + } + + public ITaskDialog Content(Action configure) + { + var builder = new TaskDialogContentBuilder(this); + configure(builder); + return this; + } + + public TaskDialogResult Show() => TaskDialogInterop.Show( + OwnerProvider(), + DialogTitle, + TextValue, // si TextValue != null → modo mínimo + PrimaryTextValue, + SecondaryTextValue, + NotificationIconValue, + ChoicesValue, + DefaultChoiceValue); +} + +// Builder interno para TaskDialog +internal sealed class TaskDialogContentBuilder(TaskDialogImpl target) : ITaskDialogContentBuilder +{ + public ITaskDialogContentBuilder NotificationIcon(TaskDialogIcon icon) + { + target.NotificationIconValue = icon; + return this; + } + + public ITaskDialogContentBuilder PrimaryText(string text) + { + target.PrimaryTextValue = text; + return this; + } + + public ITaskDialogContentBuilder SecondaryText(string text) + { + target.SecondaryTextValue = text; + return this; + } + + public ITaskDialogContentBuilder Choices(TaskDialogChoices choices) + { + target.ChoicesValue = choices; + return this; + } + + public ITaskDialogContentBuilder DefaultChoice(TaskDialogChoice choice) + { + target.DefaultChoiceValue = choice; + return this; + } +} \ No newline at end of file diff --git a/src/EasyWindowsApplication/Core/Dialogs/TaskDialogInterop.cs b/src/EasyWindowsApplication/Core/Dialogs/TaskDialogInterop.cs new file mode 100644 index 0000000..dd1b17f --- /dev/null +++ b/src/EasyWindowsApplication/Core/Dialogs/TaskDialogInterop.cs @@ -0,0 +1,147 @@ +using System.Runtime.InteropServices; +using EasyWindowsApplication.Share; + +namespace EasyWindowsApplication.Core.Dialogs; + +// Interop de TaskDialogIndirect (comctl32.dll, sin COM). +// Estructura TASKDIALOGCONFIG según commctrl.h (ver docs oficiales). +// IMPORTANTE: la ABI efectiva de esta struct usa packing de 4 (no natural +// de 8): verificado empiricamente contra el struct de CsWin32 de WinForms +// (offsets observados con Pack=4, sizeof=160). Sin Pack=4 los punteros quedan +// en offsets 8-alineados distintos y TaskDialogIndirect devuelve E_INVALIDARG +// (la API espera sizeof=160). +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct TaskDialogConfig +{ + internal uint cbSize; + internal nint hwndParent; + internal nint hInstance; + internal uint dwFlags; + internal uint dwCommonButtons; + internal nint pszWindowTitle; + internal nint pszMainIcon; + internal nint pszMainInstruction; + internal nint pszContent; + internal uint cButtons; + internal nint pButtons; + internal int nDefaultButton; + internal uint cRadioButtons; + internal nint pRadioButtons; + internal int nDefaultRadioButton; + internal nint pszVerificationText; + internal nint pszExpandedInformation; + internal nint pszExpandedControlText; + internal nint pszCollapsedControlText; + internal nint pszFooterIcon; + internal nint pszFooter; + internal nint pfCallback; + internal nint lpCallbackData; + internal uint cxWidth; +} + +internal static partial class TaskDialogInterop +{ + [LibraryImport("comctl32.dll")] + private static partial int TaskDialogIndirect( + ref TaskDialogConfig pTaskConfig, + out int pnButton, + out int pnRadioButton, + out int pfVerificationFlagChecked); + + // TDCBF_* (commctrl.h) + internal const uint TDCBF_OK_BUTTON = 0x0001; + internal const uint TDCBF_YES_BUTTON = 0x0002; + internal const uint TDCBF_NO_BUTTON = 0x0004; + internal const uint TDCBF_CANCEL_BUTTON = 0x0008; + internal const uint TDCBF_RETRY_BUTTON = 0x0010; + internal const uint TDCBF_CLOSE_BUTTON = 0x0020; + + // TDF_* usados en v1 + internal const uint TDF_ALLOW_DIALOG_CANCELLATION = 0x0008; + internal const uint TDF_POSITION_RELATIVE_TO_WINDOW = 0x1000; + + // MAKEINTRESOURCEW(TD_*_ICON) (commctrl.h) + internal static readonly nint TD_WARNING_ICON = 65535; + internal static readonly nint TD_ERROR_ICON = 65534; + internal static readonly nint TD_INFORMATION_ICON = 65533; + internal static readonly nint TD_SHIELD_ICON = 65532; + + internal static TaskDialogResult Show( + nint owner, + string? title, + string? text, + string? primaryText, + string? secondaryText, + TaskDialogIcon notificationIcon, + TaskDialogChoices choices, + TaskDialogChoice defaultChoice) + { + // Modo mínimo: TextValue != null → el texto único va en pszMainInstruction. + // Modo builder: PrimaryText → pszMainInstruction, SecondaryText → pszContent. + string? instruction = text ?? primaryText; + string? content = text is not null ? null : secondaryText; + nint titlePtr = AllocOrNull(title); + nint instructionPtr = AllocOrNull(instruction); + nint contentPtr = AllocOrNull(content); + try + { + var config = new TaskDialogConfig + { + hwndParent = owner, + hInstance = 0, + dwFlags = TDF_POSITION_RELATIVE_TO_WINDOW | TDF_ALLOW_DIALOG_CANCELLATION, + dwCommonButtons = (uint)choices, + pszWindowTitle = titlePtr, + pszMainIcon = MapIcon(notificationIcon), + pszMainInstruction = instructionPtr, + pszContent = contentPtr, + nDefaultButton = (int)defaultChoice, + cxWidth = 0 + }; + config.cbSize = (uint)Marshal.SizeOf(); + + int hr = TaskDialogIndirect(ref config, out int button, out _, out _); + if (hr != 0) + return new TaskDialogResult(true, TaskDialogChoice.Cancel); + + var mapped = MapButton(button); + bool canceled = button is 2 or 0; + return new TaskDialogResult(canceled, mapped); + } + finally + { + FreeOrNull(titlePtr); + FreeOrNull(instructionPtr); + FreeOrNull(contentPtr); + } + } + + private static nint MapIcon(TaskDialogIcon icon) => icon switch + { + TaskDialogIcon.Information => TD_INFORMATION_ICON, + TaskDialogIcon.Warning => TD_WARNING_ICON, + TaskDialogIcon.Error => TD_ERROR_ICON, + TaskDialogIcon.Shield => TD_SHIELD_ICON, + _ => 0 + }; + + private static TaskDialogChoice MapButton(int id) => id switch + { + 1 => TaskDialogChoice.Ok, + 6 => TaskDialogChoice.Yes, + 7 => TaskDialogChoice.No, + 2 => TaskDialogChoice.Cancel, + 4 => TaskDialogChoice.Retry, + 8 => TaskDialogChoice.Close, + _ => TaskDialogChoice.None + }; + + private static nint AllocOrNull(string? s) => + string.IsNullOrEmpty(s) ? 0 : Marshal.StringToCoTaskMemUni(s); + + private static void FreeOrNull(nint p) + { + if (p != 0) + Marshal.FreeCoTaskMem(p); + } +} diff --git a/src/EasyWindowsApplication/Core/Entities.cs b/src/EasyWindowsApplication/Core/Entities.cs index 85c06ee..2526c54 100644 --- a/src/EasyWindowsApplication/Core/Entities.cs +++ b/src/EasyWindowsApplication/Core/Entities.cs @@ -35,6 +35,15 @@ internal struct POINT public int Y; } +[StructLayout(LayoutKind.Sequential)] +internal struct RECT +{ + public int Left; + public int Top; + public int Right; + public int Bottom; +} + [StructLayout(LayoutKind.Sequential)] internal struct NMHDR { @@ -113,6 +122,45 @@ internal struct MONITORINFO public uint dwFlags; } +[StructLayout(LayoutKind.Sequential)] +internal struct MARGINS +{ + public int cxLeftWidth; + public int cxRightWidth; + public int cyTopHeight; + public int cyBottomHeight; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MINMAXINFO +{ + public POINT ptReserved; + public POINT ptMaxSize; + public POINT ptMaxPosition; + public POINT ptMinTrackSize; + public POINT ptMaxTrackSize; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct CWPSTRUCT +{ + public nint lParam; + public nint wParam; + public uint message; + public nint hwnd; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct APPBARDATA +{ + public uint cbSize; + public nint hWnd; + public uint uCallbackMessage; + public uint uEdge; + public RECT rc; + public int lParam; +} + [StructLayout(LayoutKind.Sequential)] internal struct DRAWITEMSTRUCT { diff --git a/src/EasyWindowsApplication/Core/HandleRegistry.cs b/src/EasyWindowsApplication/Core/HandleRegistry.cs index a684009..900f7db 100644 --- a/src/EasyWindowsApplication/Core/HandleRegistry.cs +++ b/src/EasyWindowsApplication/Core/HandleRegistry.cs @@ -1,154 +1,231 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; +using System.Diagnostics; using EasyWindowsApplication.Win32ControlsModule.Frontend; using EasyWindowsApplication.Share; namespace EasyWindowsApplication.Core; +internal enum EntityKind : byte { Control, Window, Surface } + +/// Política de baja de una ventana: hgwo muere (secundaria, re-creativa) o entidad muere (principal/teardown). +internal enum RegistryPolicy : byte { DetachHandle, Remove } + +/// +/// Entidad registrada. Hwnd=0 para superficies sin HWND (menús, tray, diálogos) +/// o ventanas secundarias destruidas (DetachHandle conserva la identidad por Name). +/// +internal sealed class EntityEntry +{ + internal int Id; + internal EntityKind Kind; + internal nint Hwnd; + internal string? Name; // null = anónima (no accesible vía Behavior) + internal object? Strong; // Window / Surface (referencia fuerte) + internal WeakReference? Weak; // Control (débil: el dueño es el modelo de layout) + internal List? ChildrenIds; // solo ventanas (hijos por hwnd al Attach) + + internal object? Target => + Strong ?? (Weak is { } w && w.TryGetTarget(out var t) ? t : null); +} + +/// +/// Almacén único de entidades con índices derivados. +/// Verdad: . Proyecciones solo escritas por el motor: +/// (hwnd→Id) y ((kind,name)→Id). +/// Ningún llamante escribe índices directamente — elimina la clase de bug #50 +/// por construcción: DetachHandle (hwnd OS muere, identidad vive) vs Remove (identidad muere). +/// +/// El trampolín estático queda fuera del modelo a propósito: +/// es plumbing del WndProc [UnmanagedCallersOnly]/GCHandle, no una entidad. +/// internal sealed class HandleRegistry { private static readonly ConcurrentDictionary _routersByHwnd = new(); internal static void RegisterRouter(nint hwnd, MasterRouter router) => _routersByHwnd[hwnd] = router; - internal static void UnregisterRouter(nint hwnd) => _routersByHwnd.TryRemove(hwnd, out _); - internal static MasterRouter? GetRouter(nint hwnd) => _routersByHwnd.TryGetValue(hwnd, out var r) ? r : null; - private readonly Dictionary> _hwndToControl = new(); - private readonly Dictionary> _nameToControl = new(StringComparer.Ordinal); - private readonly Dictionary _windowByName = new(StringComparer.Ordinal); - private readonly Dictionary _hwndToWindow = new(); - private readonly Dictionary> _windowToControls = new(); - - internal void Register(nint hwnd, IControl control) + internal static void UnregisterRouter(nint hwnd) { - _hwndToControl[hwnd] = new WeakReference(control); - if (!string.IsNullOrEmpty(control.Name)) - _nameToControl[control.Name] = new WeakReference(control); + _routersByHwnd.TryRemove(hwnd, out _); + // Nota: el assert "router y registry no desincronizados" vive del lado del registry + // (DebugAssertHwndUnregistered), llamada desde MasterRouter.WM_DESTROY justo antes. } - internal void Unregister(nint hwnd) + /// + /// Assert cross-check router↔registry para DEBUG: tras DetachWindowByHwnd + UnregisterRouter, + /// el hwnd ya no debe existir en los índices de entidades. + /// + internal void DebugAssertHwndUnregistered(nint hwnd) + => Debug.Assert(!_byHwnd.ContainsKey(hwnd), + $"Registry leak: hwnd 0x{hwnd:X} sigue en _byHwnd tras detach/remove en WM_DESTROY."); + internal static MasterRouter? GetRouter(nint hwnd) => _routersByHwnd.TryGetValue(hwnd, out var r) ? r : null; + + private int _nextId = 1; + private readonly Dictionary _entries = []; + private readonly Dictionary _byHwnd = []; + private readonly Dictionary<(EntityKind Kind, string Name), int> _byName = []; + + // ── Motor ──────────────────────────────────────────────────────────────── + + /// + /// Alta atómica: inserta Entry + proyecciones. parentHwnd enlaza jerarquía + /// (ventana o control padre — el grafo sigue siendo hwnd-keyed como antes). + /// + internal int Attach(EntityKind kind, object target, nint hwnd = 0, string? name = null, nint parentHwnd = 0) { - if (_hwndToControl.TryGetValue(hwnd, out var weak)) - { - if (weak.TryGetTarget(out var ctrl) && !string.IsNullOrEmpty(ctrl.Name)) - { - // Solo elimina la entrada de nombre si apunta al mismo control - if (_nameToControl.TryGetValue(ctrl.Name, out var nameWeak) - && nameWeak.TryGetTarget(out var nameCtrl) - && ReferenceEquals(nameCtrl, ctrl)) - { - _nameToControl.Remove(ctrl.Name); - } - } - _hwndToControl.Remove(hwnd); - } + var entry = new EntityEntry { Id = _nextId++, Kind = kind, Hwnd = hwnd, Name = name }; + if (kind == EntityKind.Control) + entry.Weak = new WeakReference(target); + else + entry.Strong = target; + + _entries[entry.Id] = entry; + if (hwnd != 0) _byHwnd[hwnd] = entry.Id; + if (!string.IsNullOrEmpty(name)) _byName[(kind, name)] = entry.Id; + + if (parentHwnd != 0 + && _byHwnd.TryGetValue(parentHwnd, out var parentId) + && _entries.TryGetValue(parentId, out var parent)) + (parent.ChildrenIds ??= []).Add(entry.Id); + + return entry.Id; } - internal void ClearAllControls() + /// El HWND OS murió: borra la proyección hwnd; la identidad (Name) sobrevive. + internal void DetachHandle(int id) { - _hwndToControl.Clear(); - _nameToControl.Clear(); - _windowToControls.Clear(); + if (!_entries.TryGetValue(id, out var e)) return; + if (e.Hwnd != 0) _byHwnd.Remove(e.Hwnd); + e.Hwnd = 0; } - internal void TrackChildWindow(nint parentHwnd, nint childHwnd) + /// La entidad deja de existir: sale de TODOS los índices, con cascada sobre hijos. + internal void Remove(int id) { - if (!_windowToControls.TryGetValue(parentHwnd, out var list)) - { - list = new List(); - _windowToControls[parentHwnd] = list; - } - list.Add(childHwnd); + if (!_entries.Remove(id, out var e)) return; + if (e.ChildrenIds is { } kids) + foreach (var childId in kids) Remove(childId); + if (e.Hwnd != 0) _byHwnd.Remove(e.Hwnd); + if (!string.IsNullOrEmpty(e.Name) + && _byName.TryGetValue((e.Kind, e.Name), out var existing) + && existing == id) + _byName.Remove((e.Kind, e.Name)); } - internal void UnregisterWindowControls(nint parentHwnd) + /// + /// Dispatcher único para WM_DESTROY: cascada de controles (mueren siempre, + /// se re-registran en re-creación) + política explícita sobre la ventana. + /// + internal void DetachWindowByHwnd(nint hwnd, RegistryPolicy policy) { - if (!_windowToControls.TryGetValue(parentHwnd, out var direct)) + if (!_byHwnd.TryGetValue(hwnd, out var id) + || !_entries.TryGetValue(id, out var window) + || window.Kind != EntityKind.Window) return; - var stack = new Stack(direct); - _windowToControls.Remove(parentHwnd); - while (stack.Count > 0) - { - var h = stack.Pop(); - Unregister(h); - if (_windowToControls.TryGetValue(h, out var sub)) - { - foreach (var ch in sub) stack.Push(ch); - _windowToControls.Remove(h); - } - } + + // Controles hijos: baja completa (hwnd + name). Re-Attach al re-crear. + if (window.ChildrenIds is { } kids) + foreach (var childId in kids) Remove(childId); + window.ChildrenIds = null; + + if (policy == RegistryPolicy.Remove) + Remove(id); + else + DetachHandle(id); // secundaria: el nombre vive para el próximo Show() } - internal void RegisterWindow(IBaseWindow window) + /// Teardown determinístico: vacía toda la tabla (post message-loop). + internal void RemoveAll() { - if (!string.IsNullOrEmpty(window.Name)) - _windowByName[window.Name] = window; - if (window.Hwnd != 0) - _hwndToWindow[window.Hwnd] = window; + var ids = new List(_entries.Keys); + foreach (var id in ids) Remove(id); + Debug.Assert(_entries.Count == 0 && _byHwnd.Count == 0 && _byName.Count == 0, + "HandleRegistry debe quedar vacía tras teardown (leak de entidad)."); } - internal void UnregisterWindow(string name) + // ── Lookups (fachada 1:1 sobre el motor) ───────────────────────────────── + + private EntityEntry? GetLive(int id) { - if (!string.IsNullOrEmpty(name)) + // Limpieza perezosa centralizada de controles débiles muertos. + if (!_entries.TryGetValue(id, out var e)) return null; + if (e.Kind == EntityKind.Control && e.Target is null) { - if (_windowByName.TryGetValue(name, out var w)) - { - _windowByName.Remove(name); - if (w.Hwnd != 0) _hwndToWindow.Remove(w.Hwnd); - } + Remove(id); // cascada segura: el control y sus hijos ya no existen + return null; } - } - - internal void UnregisterWindowByHwnd(nint hwnd) - { - _hwndToWindow.Remove(hwnd); - // también limpiar por nombre si existe - string? keyToRemove = null; - foreach (var kv in _windowByName) - if (kv.Value.Hwnd == hwnd) { keyToRemove = kv.Key; break; } - if (keyToRemove != null) _windowByName.Remove(keyToRemove); + return e; } internal IBaseWindow? GetWindowByHwnd(nint hwnd) - => _hwndToWindow.TryGetValue(hwnd, out var w) ? w : null; + => _byHwnd.TryGetValue(hwnd, out var id) && _entries.TryGetValue(id, out var e) && e.Kind == EntityKind.Window + ? (IBaseWindow?)e.Target + : null; internal T? GetWindowByHwnd(nint hwnd) where T : class, IBaseWindow => GetWindowByHwnd(hwnd) as T; internal IControl? GetByHwnd(nint hwnd) - { - if (_hwndToControl.TryGetValue(hwnd, out var weak)) - { - if (weak.TryGetTarget(out var c)) - return c; - // Entrada muerta: limpieza perezosa - _hwndToControl.Remove(hwnd); - } - return null; - } + => _byHwnd.TryGetValue(hwnd, out var id) ? GetLive(id)?.Target as IControl : null; internal IControl? GetByName(string name) - { - if (_nameToControl.TryGetValue(name, out var weak)) - { - if (weak.TryGetTarget(out var c)) - return c; - _nameToControl.Remove(name); - } - return null; - } + => _byName.TryGetValue((EntityKind.Control, name), out var id) + ? GetLive(id)?.Target as IControl + : null; internal T? GetByName(string name) where T : class, IControl => GetByName(name) as T; + internal IBaseWindow? GetWindow(string name) + => _byName.TryGetValue((EntityKind.Window, name), out var id) && _entries.TryGetValue(id, out var e) + ? (IBaseWindow?)e.Target + : null; + internal T? GetWindow(string name) where T : class, IBaseWindow - => _windowByName.TryGetValue(name, out var w) ? w as T : null; + => GetWindow(name) as T; - internal IBaseWindow? GetWindow(string name) - => _windowByName.TryGetValue(name, out var w) ? w : null; + internal IViewSurface? GetSurface(string name) + => _byName.TryGetValue((EntityKind.Surface, name), out var id) && _entries.TryGetValue(id, out var e) + ? (IViewSurface?)e.Target + : null; + + internal T? GetSurface(string name) where T : class, IViewSurface + => GetSurface(name) as T; internal IEnumerable AllControlHandles() { - foreach (var kv in _hwndToControl) - yield return kv.Key; + foreach (var e in _entries.Values) + if (e.Kind == EntityKind.Control && e.Hwnd != 0 && e.Target is not null) + yield return e.Hwnd; + } + + /// Instantánea de ventanas con HWND vivo (para teardown determinístico). + internal List LiveWindows() + { + var list = new List(); + foreach (var e in _entries.Values) + if (e.Kind == EntityKind.Window && e.Hwnd != 0 && e.Target is IBaseWindow w) + list.Add(w); + return list; + } + + /// + /// Alta (o re-alta tras re-creación) de ventana. + /// Upsert por nombre: la identidad sobrevive ciclos Close()/Show() (#50). + /// Solo el Hwnd cambia en cada ciclo. + /// + internal void RegisterWindow(IBaseWindow window) + { + if (!string.IsNullOrEmpty(window.Name) + && _byName.TryGetValue((EntityKind.Window, window.Name), out var existingId) + && _entries.TryGetValue(existingId, out var existing) + && ReferenceEquals(existing.Target, window)) + { + if (existing.Hwnd != 0) _byHwnd.Remove(existing.Hwnd); + existing.Hwnd = window.Hwnd; + if (window.Hwnd != 0) _byHwnd[window.Hwnd] = existing.Id; + return; + } + Attach(EntityKind.Window, window, window.Hwnd, window.Name); } } diff --git a/src/EasyWindowsApplication/Core/LayoutEngine/ChildrenBuilderImpl.cs b/src/EasyWindowsApplication/Core/LayoutEngine/ChildrenBuilderImpl.cs index f39cc3c..5b073e2 100644 --- a/src/EasyWindowsApplication/Core/LayoutEngine/ChildrenBuilderImpl.cs +++ b/src/EasyWindowsApplication/Core/LayoutEngine/ChildrenBuilderImpl.cs @@ -1,7 +1,6 @@ using EasyWindowsApplication.Common; using EasyWindowsApplication.Core; using EasyWindowsApplication.Share; -using EasyWindowsApplication.Win32ControlsModule.Frontend; namespace EasyWindowsApplication.Core.LayoutEngine; @@ -11,7 +10,19 @@ internal sealed class ChildrenBuilderImpl : IChildrenBuilder internal ChildrenBuilderImpl(ContentModel content) => _content = content; - public IChildrenBuilder View(Action> configure) where T : class, IControl + public IChildrenBuilder View() where T : class, IViewSurface + { + var view = new View(); + _content.Children.Add(new ViewModel + { + Name = view.PendingName ?? "", + ControlType = typeof(T), + Configure = view.BuildConfigure() + }); + return this; + } + + public IChildrenBuilder View(Action> configure) where T : class, IViewSurface { var view = new View(); configure(view); @@ -25,7 +36,7 @@ public IChildrenBuilder View(Action> configure) where T : class, ICon return this; } - public IChildrenBuilder View(Func, View> configure) where T : class, IControl + public IChildrenBuilder View(Func, View> configure) where T : class, IViewSurface { var view = new View(); var result = configure(view); diff --git a/src/EasyWindowsApplication/Core/LayoutEngine/LayoutBuilderImpl.cs b/src/EasyWindowsApplication/Core/LayoutEngine/LayoutBuilderImpl.cs index e7556e4..657362e 100644 --- a/src/EasyWindowsApplication/Core/LayoutEngine/LayoutBuilderImpl.cs +++ b/src/EasyWindowsApplication/Core/LayoutEngine/LayoutBuilderImpl.cs @@ -38,4 +38,21 @@ public ILayoutBuilderAfterWindow AlternativeWindow(Action configu _app.AddWindow(model); return this; } + + public ILayoutBuilderAfterWindow AlternativeWindow(Action configure) where T : class, IViewSurface + { + var model = new WindowModel { IsAlternative = true, SurfaceType = typeof(T) }; + var config = new WindowConfigBuilder(model); + configure(config); + _app.AddWindow(model); + return this; + } + + public ILayoutBuilderAfterWindow AlternativeWindow(Action configure) where TDialog : class, ISystemDialog + { + var model = new WindowModel { IsAlternative = true, SurfaceType = typeof(TDialog) }; + model.ConfigureSurface = s => configure((TDialog)s); + _app.AddWindow(model); + return this; + } } diff --git a/src/EasyWindowsApplication/Core/LayoutEngine/WindowConfigBuilder.cs b/src/EasyWindowsApplication/Core/LayoutEngine/WindowConfigBuilder.cs index b56594d..066c73a 100644 --- a/src/EasyWindowsApplication/Core/LayoutEngine/WindowConfigBuilder.cs +++ b/src/EasyWindowsApplication/Core/LayoutEngine/WindowConfigBuilder.cs @@ -28,6 +28,25 @@ public IWindowConfig Dimensions(int width, int height) return this; } + public IWindowConfig MinSize(int width, int height) + { + _model.MinSize = (width, height); + return this; + } + + public IWindowConfig MaxSize(int width, int height) + { + _model.MaxSize = (width, height); + return this; + } + + public IWindowConfig Position(IWindowPosition position) + { + _model.WindowPosition = position; + return this; + } + + [Obsolete("Use Position(IWindowPosition) con PositionPreset(WindowPositionPreset.Center) para lo mismo. Será eliminado en la próxima major.")] public IWindowConfig Position(WindowPositionOnScreen position) { _model.Position = position; @@ -40,6 +59,22 @@ public IWindowConfig Background(Color color) return this; } + public IWindowConfig Resizable(bool resizable) { _model.Resizable = resizable; return this; } + public IWindowConfig CaptionButtons(CaptionButtons buttons) { _model.CaptionButtons = buttons; return this; } + public IWindowConfig TopMost(bool topMost) { _model.TopMost = topMost; return this; } + public IWindowConfig Opacity(double opacity) { _model.Opacity = Math.Clamp(opacity, 0.0, 1.0); return this; } + public IWindowConfig SystemBackdrop(WindowBackdrop backdrop) { _model.Backdrop = backdrop; return this; } + public IWindowConfig ScreenCapture(ScreenCaptureMode mode) { _model.ScreenCapture = mode; return this; } + public IWindowConfig WindowState(WindowState state) { _model.InitialWindowState = state; return this; } + + public IWindowConfig TitleBar(Action configure) + { + var tb = new TitleBarConfigBuilder(); + configure(tb); + _model.TitleBar = tb.Model; + return this; + } + public IWindowConfig Scroll(Action configure) { var cfg = new WindowsScroll(); @@ -48,6 +83,12 @@ public IWindowConfig Scroll(Action configure) return this; } + public IWindowConfig SystemTray(Action configure) + { + _model.SystemTrayConfigure = configure; + return this; + } + public IWindowContentConfig Content(Action configure) where TLayout : IStackLayout => RegisterContent(configure); @@ -64,3 +105,18 @@ private IWindowContentConfig RegisterContent(Action configure) } internal sealed class WindowContentConfig : IWindowContentConfig { } + +internal sealed class TitleBarConfigBuilder : ITitleBarConfig +{ + internal TitleBarBuilder Model { get; } = new(); + public TitleBarMode Mode => Model.Mode; + + public ITitleBarConfig Icon(nint hIcon) { Model.Icon = hIcon; return this; } + public ITitleBarConfig Title(string title) { Model.Title = title; return this; } + public ITitleBarConfig Custom(Action configure) where TView : class, IViewSurface + { + Model.Mode = TitleBarMode.Custom; + Model.ConfigureCustom = v => configure((TView)v); + return this; + } +} diff --git a/src/EasyWindowsApplication/Core/LayoutModels.cs b/src/EasyWindowsApplication/Core/LayoutModels.cs index 035ce8b..01c58b7 100644 --- a/src/EasyWindowsApplication/Core/LayoutModels.cs +++ b/src/EasyWindowsApplication/Core/LayoutModels.cs @@ -1,5 +1,4 @@ using EasyWindowsApplication.Share; -using EasyWindowsApplication.Win32ControlsModule.Frontend; namespace EasyWindowsApplication.Core; @@ -17,6 +16,31 @@ internal sealed class WindowModel internal bool IsAlternative { get; set; } internal IContentModel? Content { get; set; } internal WindowsScroll? ScrollConfig { get; set; } + internal Action? SystemTrayConfigure { get; set; } + internal Type? SurfaceType { get; set; } + internal Action? ConfigureSurface { get; set; } + + // Phase A/B — #47 + internal TitleBarBuilder? TitleBar { get; set; } + internal (int W, int H)? MinSize { get; set; } + internal (int W, int H)? MaxSize { get; set; } + internal bool Resizable { get; set; } = true; + internal CaptionButtons CaptionButtons { get; set; } = CaptionButtons.All; + internal bool TopMost { get; set; } + internal double Opacity { get; set; } = 1.0; + internal WindowBackdrop Backdrop { get; set; } = WindowBackdrop.None; + internal ScreenCaptureMode ScreenCapture { get; set; } = ScreenCaptureMode.Allow; + internal WindowState InitialWindowState { get; set; } = WindowState.Normal; + internal IWindowPosition? WindowPosition { get; set; } +} + +/// Config capturada de (icono/título/custom view). +internal sealed class TitleBarBuilder +{ + internal TitleBarMode Mode { get; set; } = TitleBarMode.Simple; + internal nint Icon { get; set; } + internal string? Title { get; set; } + internal Action? ConfigureCustom { get; set; } } internal sealed class ContentModel : IContentModel @@ -29,8 +53,8 @@ internal sealed class ContentModel : IContentModel internal sealed class ViewModel : IViewModel { internal string Name { get; set; } = ""; - internal IControl? Control { get; set; } + internal IViewSurface? Control { get; set; } internal ContentModel? SubContent { get; set; } internal Type? ControlType { get; set; } - internal Action? Configure { get; set; } + internal Action? Configure { get; set; } } diff --git a/src/EasyWindowsApplication/Core/MasterRouter.cs b/src/EasyWindowsApplication/Core/MasterRouter.cs index 8b1c78c..c8e84f3 100644 --- a/src/EasyWindowsApplication/Core/MasterRouter.cs +++ b/src/EasyWindowsApplication/Core/MasterRouter.cs @@ -1,7 +1,10 @@ -using System.Runtime.CompilerServices; +using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using EasyWindowsApplication.Common; +using EasyWindowsApplication.Core.Surfaces; using EasyWindowsApplication.Core.Windowing; +using EasyWindowsApplication.Share.Input; using EasyWindowsApplication.Share; using EasyWindowsApplication.Win32ControlsModule.Frontend; @@ -9,15 +12,27 @@ namespace EasyWindowsApplication.Core; internal sealed class MasterRouter { - private readonly Dictionary<(nint Hwnd, uint Msg), Win32MessageHandler> _handlers = new(); + private readonly Dictionary<(nint Hwnd, uint Msg), Win32MessageHandler> _handlers = []; private readonly HandleRegistry _registry; private nint _mainHwnd; private bool _isResizing; - private readonly Dictionary _windowBrushes = new(); - private readonly Dictionary _controlBrushes = new(); - private readonly Dictionary> _layoutGroupBackgrounds = new(); - private readonly Dictionary _scrollOffsets = new(); + internal nint MainHwnd + { + get => _mainHwnd; + set => _mainHwnd = value; + } + + /// + /// Portón de terminación: retorna true si la salida fue vetada + /// (OnTerminating con Cancel=true). Null = sin Behavior, cierre directo. + /// + internal Func? TerminationGate; + + private readonly Dictionary _windowBrushes = []; + private readonly Dictionary _controlBrushes = []; + private readonly Dictionary> _layoutGroupBackgrounds = []; + private readonly Dictionary _scrollOffsets = []; internal MasterRouter(HandleRegistry registry) { @@ -30,7 +45,7 @@ internal void AddLayoutGroupBackground(nint hwnd, RECT rect, Color color) int colorRef = color.ToCOLORREF(); if (!_layoutGroupBackgrounds.TryGetValue(hwnd, out var list)) { - list = new List<(RECT, int)>(); + list = []; _layoutGroupBackgrounds[hwnd] = list; } list.Add((rect, colorRef)); @@ -64,6 +79,34 @@ internal void SetScrollOffset(nint hwnd, int x, int y) _scrollOffsets[hwnd] = (x, y); } + /// + /// Limpieza exhaustiva de todo estado del router keyed por . + /// Idempotente: seguro llamarlo más de una vez para el mismo hwnd. + /// Se invoca en WM_DESTROY y defensivamente en EnsureCreated (hwnd-reuse teórico del SO). + /// + internal void CleanupHwnd(nint hwnd) + { + if (_windowBrushes.Remove(hwnd, out var deadBrush) && deadBrush != 0) + Win32.DeleteObject(deadBrush); + _controlBrushes.Remove(hwnd); + _layoutGroupBackgrounds.Remove(hwnd); + _scrollOffsets.Remove(hwnd); + if (_handlers.Count > 0) + { + // Copia claves para evitar modificar durante enumeración + var toRemove = new List<(nint Hwnd, uint Msg)>(); + foreach (var key in _handlers.Keys) + if (key.Hwnd == hwnd) toRemove.Add(key); + foreach (var key in toRemove) _handlers.Remove(key); + } + + Debug.Assert(!_windowBrushes.ContainsKey(hwnd)); + Debug.Assert(!_controlBrushes.ContainsKey(hwnd)); + Debug.Assert(!_layoutGroupBackgrounds.ContainsKey(hwnd)); + Debug.Assert(!_scrollOffsets.ContainsKey(hwnd)); + Debug.Assert(!_handlers.Keys.Any(k => k.Hwnd == hwnd)); + } + internal void ClearScrollOffset(nint hwnd) { _scrollOffsets.Remove(hwnd); @@ -79,7 +122,11 @@ internal static nint WndProcTrampoline(nint hwnd, uint msg, nint wParam, nint lP } catch (Exception ex) { - System.Diagnostics.Debug.Fail(ex.ToString()); + // Tragar aquí protege la message loop (una excepción en un handler + // no tumba la app), pero Release no puede quedar en silencio: + // Debug.Fail solo existe en DEBUG, así que el Trace es el registro. + Trace.WriteLine($"[EWA] Excepción en WndProc hwnd=0x{hwnd:X16} msg=0x{msg:X8}:{Environment.NewLine}{ex}"); + Debug.Fail(ex.ToString()); return Win32.DefWindowProcW(hwnd, msg, wParam, lParam); } } @@ -89,13 +136,22 @@ internal nint WndProc(nint hwnd, uint msg, nint wParam, nint lParam) // ── Window lifecycle & resize routing (Fase 3) ── if (msg == WM.CLOSE) { - var win = _registry.GetWindowByHwnd(hwnd); - if (win != null) + // Portón de la app: la principal no tiene Closing local; + // su X pasa por OnTerminating (vetable, UI viva). + if (hwnd == _mainHwnd) { - bool cancel = false; - if (win is WindowImpl wi) cancel = wi.RaiseClosing(); - else if (win is AlternativeWindowImpl aw) cancel = aw.RaiseClosing(); - if (cancel) return 0; + if (TerminationGate?.Invoke() == true) + return 0; + // No vetada (o sin Behavior): caer a DefWindowProc → WM_DESTROY. + } + else + { + var alt = _registry.GetWindowByHwnd(hwnd); + if (alt is AlternativeWindowImpl aw) + { + if (aw.RaiseClosing()) + return 0; + } } } @@ -103,22 +159,76 @@ internal nint WndProc(nint hwnd, uint msg, nint wParam, nint lParam) { _isResizing = true; } + else if (msg == WM.GETMINMAXINFO) + { + var win = _registry.GetWindowByHwnd(hwnd); + if (win is WindowImpl wiMinMax) + { + unsafe + { + var info = (MINMAXINFO*)lParam; + wiMinMax.ApplyMinMaxInfo(ref *info); + } + return 0; + } + } + else if (msg == WM.SYSCOMMAND) + { + uint sysCmd = (uint)wParam & SC.MASK; + var win = _registry.GetWindowByHwnd(hwnd); + if (win is WindowImpl wiSys) + wiSys.SyncStateFromSysCommand(sysCmd); + if (sysCmd == SC.KEYMENU && win is IInputFeed feed) + feed.FeedTrigger(typeof(KeyMenu), 1); + // No consumo el mensaje: dejarlo llegar a DefWindowProc para el efecto real. + } + else if (msg == WM.NCACTIVATE) + { + // C4/D6: en ventana con TitleBar (Custom o Simple) respondemos TRUE para pintar la caption activa. + var win = _registry.GetWindowByHwnd(hwnd); + if (win is WindowImpl wiNc && wiNc.TitleBarActive) + return 1; // evita flicker / parpadeo del caption por redraw + } + else if (msg == WM.NCHITTEST) + { + // D5: hit-test de caption custom — solo cuando hay custom title bar. + var win = _registry.GetWindowByHwnd(hwnd); + if (win is WindowImpl wiHit && wiHit.CustomTitleBarActive) + { + POINT pt = new() { X = (short)Win32Helpers.LOWORD(lParam), Y = (short)Win32Helpers.HIWORD(lParam) }; + Core.Win32.ScreenToClient(hwnd, ref pt); + // Drag area: toda la franja de caption custom, salvo areas de botones futuros. + if (wiHit.IsCustomTitleBarHit(pt.X, pt.Y)) + return HT.CAPTION; + } + } + else if (msg == WM.NCCALCSIZE) + { + // D5: para Custom title bar, eliminamos el frame no cliente por defecto. + var win = _registry.GetWindowByHwnd(hwnd); + if (win is WindowImpl wiCust && wiCust.CustomTitleBarActive && wParam != 0) + { + // wParam=TRUE significa "calcular tamaño del cliente". Devolver 0 = conservar zona cliente tal cual + // (solo frame extendido por DwmExtendFrameIntoClientArea -1). + return 0; + } + } else if (msg == WM.EXITSIZEMOVE) { _isResizing = false; var win = _registry.GetWindowByHwnd(hwnd); - if (win != null) + if (win is not null) { Win32.GetClientRect(hwnd, out RECT rc); - int w = rc.Right - rc.Left; - int h = rc.Bottom - rc.Top; + float w = rc.Right - rc.Left; + float h = rc.Bottom - rc.Top; if (win is WindowImpl wi) wi.RaiseResized(w, h); } } else if (msg == WM.SIZE) { var win = _registry.GetWindowByHwnd(hwnd); - if (win != null) + if (win is not null) { int wParamType = (int)wParam; if (wParamType != WMSIZE.MINIMIZED) @@ -134,12 +244,18 @@ internal nint WndProc(nint hwnd, uint msg, nint wParam, nint lParam) if (win is WindowImpl wi) wi.RaiseResized(w, h); } } + + // D3: detección FullScreen por estilo (sin CAPTION/THICKFRAME + tamaño >= monitor). + if (win is WindowImpl wiFs) + wiFs.DetectFullScreenByStyle( + (int)Win32Helpers.LOWORD(lParam), + (int)Win32Helpers.HIWORD(lParam)); } } else if (msg == WM.MOVE) { var win = _registry.GetWindowByHwnd(hwnd); - if (win != null) + if (win is not null) { int x = (int)(short)Win32Helpers.LOWORD(lParam); int y = (int)(short)Win32Helpers.HIWORD(lParam); @@ -149,7 +265,7 @@ internal nint WndProc(nint hwnd, uint msg, nint wParam, nint lParam) else if (msg == WM.ACTIVATE) { var win = _registry.GetWindowByHwnd(hwnd); - if (win != null) + if (win is not null) { int state = (int)Win32Helpers.LOWORD(wParam); if (state == WA.INACTIVE) @@ -229,32 +345,23 @@ internal nint WndProc(nint hwnd, uint msg, nint wParam, nint lParam) if (msg == WM.DESTROY) { - // Disparar Closed antes de limpieza + // Disparar Closed solo en secundarias (la principal no tiene + // concepto "cerrar": su destrucción ES el ciclo de la app). var win = _registry.GetWindowByHwnd(hwnd); - if (win != null) - { - if (win is WindowImpl wi) wi.RaiseClosed(); - else if (win is AlternativeWindowImpl aw) aw.RaiseClosed(); - } - - // Limpieza determinística del registry (Fase 5: evita memory leak de controles) - _registry.Unregister(hwnd); - _registry.UnregisterWindowControls(hwnd); - _registry.UnregisterWindowByHwnd(hwnd); + if (win is AlternativeWindowImpl awClosed) + awClosed.RaiseClosed(); + + // Registry: política explícita. + // Secundarias → DetachHandle (el nombre/identidad vive para el próximo Show()). + // Principal → Remove (destrucción = fin de la app). + _registry.DetachWindowByHwnd(hwnd, + win is AlternativeWindowImpl ? RegistryPolicy.DetachHandle : RegistryPolicy.Remove); HandleRegistry.UnregisterRouter(hwnd); - _windowBrushes.Remove(hwnd); - _controlBrushes.Remove(hwnd); - _layoutGroupBackgrounds.Remove(hwnd); - _scrollOffsets.Remove(hwnd); - // Limpia handlers asociados a este hwnd (evita leak de delegates) - if (_handlers.Count > 0) - { - // Copia claves para evitar modificar durante enumeración - var toRemove = new List<(nint Hwnd, uint Msg)>(); - foreach (var key in _handlers.Keys) - if (key.Hwnd == hwnd) toRemove.Add(key); - foreach (var key in toRemove) _handlers.Remove(key); - } + _registry.DebugAssertHwndUnregistered(hwnd); // DEBUG: router↔registry sync (B-2 #52) + CleanupHwnd(hwnd); + // Marca la secundaria como destruida (Show() posterior re-crea). + if (win is AlternativeWindowImpl awDestroyed) + awDestroyed.MarkDestroyed(); if (hwnd == _mainHwnd) { Win32.PostQuitMessage(0); @@ -305,10 +412,10 @@ internal nint WndProc(nint hwnd, uint msg, nint wParam, nint lParam) return ctrlBrush; } - // 1. Try registered raw handlers (lowest level) - if (hwnd == _mainHwnd && _handlers.TryGetValue((hwnd, msg), out var mainWindowHandler)) + // 1. Try registered raw handlers (lowest level, any window incl. hidden broker windows) + if (_handlers.TryGetValue((hwnd, msg), out var windowHandler)) { - var result = mainWindowHandler(wParam, lParam); + var result = windowHandler(wParam, lParam); if (result != 0) return result; } @@ -336,8 +443,11 @@ private void DispatchTypedEvent(uint msg, nint controlHwnd, nint wParam, nint lP { case WM.COMMAND: nint code = Win32Helpers.HIWORD(wParam); - if ((code == BN.CLICKED || code == BN.DOUBLECLICKED) && control is IClickEventSource clickable) - clickable.RaiseClickInternal(); + if (control is IInputFeed feed) + { + if (code == BN.CLICKED) feed.FeedTrigger(typeof(MainTap), 1); + if (code == BN.DOUBLECLICKED) feed.FeedTrigger(typeof(MainTap), 2); + } break; } } diff --git a/src/EasyWindowsApplication/Core/Menus/MenuItem.cs b/src/EasyWindowsApplication/Core/Menus/MenuItem.cs new file mode 100644 index 0000000..21cc870 --- /dev/null +++ b/src/EasyWindowsApplication/Core/Menus/MenuItem.cs @@ -0,0 +1,41 @@ +using EasyWindowsApplication.Core.Surfaces; +using EasyWindowsApplication.Share; +using EasyWindowsApplication.Share.Input; + +namespace EasyWindowsApplication.Core.Menus; + +internal sealed class MenuItem : IMenuItem, IInputFeed +{ + private readonly SurfaceInputHub _hub = new(); + void IInputFeed.FeedTrigger(Type trigger, int count) => _hub.Fire(trigger, count); + + public string Name { get; set; } = ""; + public string Text { get; set; } = ""; + public bool IsEnabled { get; set; } = true; + public bool IsChecked { get; set; } + public LayoutLength? LayoutWidth { get; set; } + public LayoutLength? LayoutHeight { get; set; } + public LayoutOptions LayoutOptions { get; set; } = new(); + public Thickness Margin { get; set; } + public Thickness Padding { get; set; } + public DockPosition Dock { get; set; } + public int GridRow { get; set; } + public int GridColumn { get; set; } + public int GridRowSpan { get; set; } + public int GridColumnSpan { get; set; } + public Color? BackgroundColor { get; set; } + + public void OnInputWithoutSpatialPosition(Action handler) where TTrigger : IWithoutSpatialPositionTrigger + => _hub.AddNonSpatial(typeof(TTrigger), handler); + + public void OnInputWithSpatialPosition(Action handler) where TTrigger : ISpatialPositionTrigger + => OnInputWithSpatialPosition(handler); + + public void OnInputWithSpatialPosition(Action handler) + where TTrigger : ISpatialPositionTrigger + where TCount : ITapCountSpec + => _hub.AddSpatial(typeof(TTrigger), typeof(TCount), handler); + + public void OnInputWithSpatialPosition(Action handler) + => OnInputWithSpatialPosition(handler); +} diff --git a/src/EasyWindowsApplication/Core/Menus/MenuModel.cs b/src/EasyWindowsApplication/Core/Menus/MenuModel.cs new file mode 100644 index 0000000..59eec7d --- /dev/null +++ b/src/EasyWindowsApplication/Core/Menus/MenuModel.cs @@ -0,0 +1,38 @@ +namespace EasyWindowsApplication.Core.Menus; + +internal sealed class MenuItemModel +{ + internal int Id; + internal string Text = ""; + internal bool IsEnabled = true; + internal bool IsChecked; + internal bool IsSeparator; + internal List Children = []; + internal Action? OnSelected; +} + +internal sealed class MenuModel +{ + internal List Items = []; + + internal MenuItemModel? FindById(int id) + { + foreach (var item in Items) + { + var found = FindRecursive(item, id); + if (found is not null) return found; + } + return null; + } + + private static MenuItemModel? FindRecursive(MenuItemModel item, int id) + { + if (item.Id == id) return item; + foreach (var child in item.Children) + { + var found = FindRecursive(child, id); + if (found is not null) return found; + } + return null; + } +} diff --git a/src/EasyWindowsApplication/Core/Menus/MenuSeparatorItem.cs b/src/EasyWindowsApplication/Core/Menus/MenuSeparatorItem.cs new file mode 100644 index 0000000..6182253 --- /dev/null +++ b/src/EasyWindowsApplication/Core/Menus/MenuSeparatorItem.cs @@ -0,0 +1,19 @@ +using EasyWindowsApplication.Share; + +namespace EasyWindowsApplication.Core.Menus; + +internal sealed class MenuSeparatorItem : IMenuItemSeparator +{ + public string Name { get; set; } = ""; + public LayoutLength? LayoutWidth { get; set; } + public LayoutLength? LayoutHeight { get; set; } + public LayoutOptions LayoutOptions { get; set; } = new(); + public Thickness Margin { get; set; } + public Thickness Padding { get; set; } + public DockPosition Dock { get; set; } + public int GridRow { get; set; } + public int GridColumn { get; set; } + public int GridRowSpan { get; set; } + public int GridColumnSpan { get; set; } + public Color? BackgroundColor { get; set; } +} diff --git a/src/EasyWindowsApplication/Core/Menus/MenuSurface.cs b/src/EasyWindowsApplication/Core/Menus/MenuSurface.cs new file mode 100644 index 0000000..f73a037 --- /dev/null +++ b/src/EasyWindowsApplication/Core/Menus/MenuSurface.cs @@ -0,0 +1,161 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using EasyWindowsApplication.Core.Surfaces; +using EasyWindowsApplication.Share; +using EasyWindowsApplication.Share.Input; + +namespace EasyWindowsApplication.Core.Menus; + +internal sealed class MenuSurface : IMenu +{ + private readonly ContentModel? _content; + private nint _ownerHwnd; + private Func<(int X, int Y)>? _iconAnchor; + private MenuModel? _cachedModel; + + internal MenuSurface(ContentModel? content) => _content = content; + + internal HandleRegistry? Registry { get; set; } + + internal void SetOwner(nint hwnd) => _ownerHwnd = hwnd; + + internal void SetIconAnchor(nint trayHwnd, uint trayId) + { + _iconAnchor = () => + { + var identifier = new NOTIFYICONIDENTIFIER + { + cbSize = (uint)Marshal.SizeOf(), + hWnd = trayHwnd, + uID = trayId, + guidItem = Guid.Empty + }; + // S_OK (0) = rect válido: el menú se ancla al icono (foco), nunca al mouse. + // Sin rect (icono no localizable) el cursor es fallback documentado. + if (Win32.Shell_NotifyIconGetRect(ref identifier, out var rect) == 0) + return (rect.Right, rect.Top); + + Win32.GetCursorPos(out var pt); + return (pt.X, pt.Y); + }; + } + + public string Name { get; set; } = ""; + public LayoutLength? LayoutWidth { get; set; } + public LayoutLength? LayoutHeight { get; set; } + public LayoutOptions LayoutOptions { get; set; } = new(); + public Thickness Margin { get; set; } + public Thickness Padding { get; set; } + public DockPosition Dock { get; set; } + public int GridRow { get; set; } + public int GridColumn { get; set; } + public int GridRowSpan { get; set; } + public int GridColumnSpan { get; set; } + public Color? BackgroundColor { get; set; } + + public void Show() + { + if (_iconAnchor is not null) + { + var (x, y) = _iconAnchor(); + ShowAt(x, y, TPM.RIGHTALIGN | TPM.BOTTOMALIGN); + } + else + { + ShowAtCursor(); + } + } + + public void ShowAtCursor() + { + Win32.GetCursorPos(out var pt); + ShowAt(pt.X, pt.Y, TPM.LEFTALIGN | TPM.TOPALIGN); + } + + public void ShowAtIcon() + { + if (_iconAnchor is not null) + { + var (x, y) = _iconAnchor(); + ShowAt(x, y, TPM.RIGHTALIGN | TPM.BOTTOMALIGN); + } + else + { + ShowAtCursor(); + } + } + + public void Hide() { } + + public void Close() { } + + internal MenuModel BuildModel() + { + var model = new MenuModel(); + if (_content is null) return model; + foreach (var child in _content.Children) + { + if (child is not ViewModel vm) continue; + var surface = CreateSurface(vm); + model.Items.Add(ToItemModel(surface, vm.SubContent)); + } + return model; + } + + internal MenuModel EnsureMaterialized() + { + _cachedModel ??= BuildModel(); + return _cachedModel; + } + + private static IViewSurface CreateSurface(ViewModel vm) + { + if (vm.Control is IViewSurface existing) return existing; + IViewSurface surface = vm.ControlType is not null + && typeof(IMenuItemSeparator).IsAssignableFrom(vm.ControlType) + ? new MenuSeparatorItem() + : new MenuItem(); + if (!string.IsNullOrEmpty(vm.Name)) + surface.Name = vm.Name; + vm.Configure?.Invoke(surface); + return surface; + } + + private MenuItemModel ToItemModel(IViewSurface surface, ContentModel? sub) + { + Registry?.Attach(EntityKind.Surface, surface, hwnd: 0, surface.Name); + if (surface is IMenuItemSeparator) + { + return new MenuItemModel + { + Id = Win32MenuEngine.NextId(), + IsSeparator = true + }; + } + var item = (IMenuItem)surface; + var model = new MenuItemModel + { + Id = Win32MenuEngine.NextId(), + Text = item.Text, + IsEnabled = item.IsEnabled, + IsChecked = item.IsChecked, + OnSelected = () => ((IInputFeed)item).FeedTrigger(typeof(MainTap), 1) + }; + if (sub is not null) + { + foreach (var child in sub.Children) + { + if (child is not ViewModel vm) continue; + var subSurface = CreateSurface(vm); + model.Children.Add(ToItemModel(subSurface, vm.SubContent)); + } + } + return model; + } + + private void ShowAt(int x, int y, uint align) + { + var model = EnsureMaterialized(); + Win32MenuEngine.Show(_ownerHwnd, model, x, y, align); + } +} diff --git a/src/EasyWindowsApplication/Core/Menus/Win32MenuEngine.cs b/src/EasyWindowsApplication/Core/Menus/Win32MenuEngine.cs new file mode 100644 index 0000000..914ad6a --- /dev/null +++ b/src/EasyWindowsApplication/Core/Menus/Win32MenuEngine.cs @@ -0,0 +1,92 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace EasyWindowsApplication.Core.Menus; + +internal static class Win32MenuEngine +{ + private static int _nextId; + + internal static int NextId() => Interlocked.Increment(ref _nextId); + + internal static nint BuildMenu(MenuModel model) + { + nint hMenu = Win32.CreatePopupMenu(); + if (hMenu == 0) + throw new InvalidOperationException("CreatePopupMenu failed."); + try + { + AppendItems(hMenu, model.Items); + } + catch + { + Win32.DestroyMenu(hMenu); + throw; + } + return hMenu; + } + + internal static bool Dispatch(MenuModel model, int id) + { + if (id <= 0) return false; + var item = model.FindById(id); + if (item is null || !item.IsEnabled || item.IsSeparator) return false; + item.OnSelected?.Invoke(); + return true; + } + + internal static int Show(nint hwndOwner, MenuModel model, int x, int y, uint align = TPM.LEFTALIGN | TPM.TOPALIGN) + { + nint hMenu = BuildMenu(model); + try + { + Win32.SetForegroundWindow(hwndOwner); + int id = Win32.TrackPopupMenuEx(hMenu, align | TPM.RETURNCMD, x, y, hwndOwner, 0); + Win32.PostMessageW(hwndOwner, WM.NULL, 0, 0); + if (id > 0) Dispatch(model, id); + return id; + } + finally + { + Win32.DestroyMenu(hMenu); + } + } + + private static void AppendItems(nint hMenu, List items) + { + foreach (var item in items) + { + if (item.IsSeparator) + { + Win32.AppendMenuW(hMenu, MF.SEPARATOR, 0, null); + continue; + } + if (item.Children.Count > 0) + { + nint hSub = Win32.CreatePopupMenu(); + if (hSub == 0) + throw new InvalidOperationException("CreatePopupMenu failed (submenu)."); + try + { + AppendItems(hSub, item.Children); + } + catch + { + Win32.DestroyMenu(hSub); + throw; + } + uint flags = MF.POPUP; + if (!item.IsEnabled) flags |= MF.GRAYED; + if (item.IsChecked) flags |= MF.CHECKED; + Win32.AppendMenuW(hMenu, flags, (nuint)hSub, item.Text); + } + else + { + uint flags = MF.STRING; + if (!item.IsEnabled) flags |= MF.GRAYED; + if (item.IsChecked) flags |= MF.CHECKED; + Win32.AppendMenuW(hMenu, flags, (nuint)item.Id, item.Text); + } + } + } +} diff --git a/src/EasyWindowsApplication/Core/RECT.cs b/src/EasyWindowsApplication/Core/RECT.cs deleted file mode 100644 index b4287fd..0000000 --- a/src/EasyWindowsApplication/Core/RECT.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Runtime.InteropServices; - -namespace EasyWindowsApplication.Core; - -[StructLayout(LayoutKind.Sequential)] -internal struct RECT -{ - public int Left; - public int Top; - public int Right; - public int Bottom; -} diff --git a/src/EasyWindowsApplication/Core/ResourcesDictionaryImpl.cs b/src/EasyWindowsApplication/Core/ResourcesDictionaryImpl.cs index 58ee463..adabac8 100644 --- a/src/EasyWindowsApplication/Core/ResourcesDictionaryImpl.cs +++ b/src/EasyWindowsApplication/Core/ResourcesDictionaryImpl.cs @@ -1,5 +1,6 @@ namespace EasyWindowsApplication.Core; +using System.Globalization; using EasyWindowsApplication.Share; internal sealed class ResourcesDictionaryImpl : IResourcesDictionary @@ -23,6 +24,7 @@ public IResourcesDictionary Services(Action configure) internal sealed class SettingsBuilderImpl : ISettingsBuilder { internal AppConfigFileConfig AppConfig { get; } = new(); + internal CultureInfo? SelectedCulture { get; private set; } public ISettingsBuilder UseWinApi() { @@ -34,6 +36,12 @@ public ISettingsBuilder AppConfigFile(Action configure) configure(new AppConfigFileBuilderImpl(AppConfig)); return this; } + + public ISettingsBuilder Culture(CultureInfo cultureInfo) + { + SelectedCulture = cultureInfo; + return this; + } } internal sealed class AppConfigFileConfig diff --git a/src/EasyWindowsApplication/Core/ShellEntities.cs b/src/EasyWindowsApplication/Core/ShellEntities.cs new file mode 100644 index 0000000..4a19f8a --- /dev/null +++ b/src/EasyWindowsApplication/Core/ShellEntities.cs @@ -0,0 +1,32 @@ +using System.Runtime.InteropServices; + +namespace EasyWindowsApplication.Core; + +[StructLayout(LayoutKind.Sequential)] +internal struct NOTIFYICONIDENTIFIER +{ + public uint cbSize; + public nint hWnd; + public uint uID; + public Guid guidItem; +} + +[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] +internal unsafe struct NOTIFYICONDATAW +{ + public uint cbSize; + public nint hWnd; + public uint uID; + public uint uFlags; + public uint uCallbackMessage; + public nint hIcon; + public fixed char szTip[128]; + public uint dwState; + public uint dwStateMask; + public fixed char szInfo[256]; + public uint uVersion; + public fixed char szInfoTitle[64]; + public uint dwInfoFlags; + public Guid guidItem; + public nint hBalloonIcon; +} diff --git a/src/EasyWindowsApplication/Core/Surfaces/IInputFeed.cs b/src/EasyWindowsApplication/Core/Surfaces/IInputFeed.cs new file mode 100644 index 0000000..094b150 --- /dev/null +++ b/src/EasyWindowsApplication/Core/Surfaces/IInputFeed.cs @@ -0,0 +1,10 @@ +namespace EasyWindowsApplication.Core.Surfaces; + +/// +/// Punto único de entrada OS → hub. Lo implementan las superficies con +/// alimentación externa (, items de menú, ventanas). +/// +internal interface IInputFeed +{ + void FeedTrigger(Type trigger, int count); +} diff --git a/src/EasyWindowsApplication/Core/Surfaces/SurfaceInputHub.cs b/src/EasyWindowsApplication/Core/Surfaces/SurfaceInputHub.cs new file mode 100644 index 0000000..6d67cd1 --- /dev/null +++ b/src/EasyWindowsApplication/Core/Surfaces/SurfaceInputHub.cs @@ -0,0 +1,64 @@ +namespace EasyWindowsApplication.Core.Surfaces; + +/// +/// Motor de dispatch unificado extraído de SystemTrayImpl. +/// Clave unificada (Trigger, CountSpec) donde CountSpec ∈ {OneTap..TenTap, AnyTap, NoTap}. +/// Todo corre en el hilo de UI (message loop); sin locks. +/// +internal sealed class SurfaceInputHub +{ + private static readonly Type[] TapCountTypes = + [ + typeof(Share.Input.OneTap), typeof(Share.Input.TwoTap), typeof(Share.Input.ThreeTap), + typeof(Share.Input.FourTap), typeof(Share.Input.FiveTap), typeof(Share.Input.SixTap), + typeof(Share.Input.SevenTap), typeof(Share.Input.EightTap), typeof(Share.Input.NineTap), + typeof(Share.Input.TenTap) + ]; + + private readonly Dictionary<(Type Trigger, Type CountSpec), List> _spatial = []; + private readonly Dictionary> _nonSpatial = []; + + public void AddSpatial(Type trigger, Type countSpec, Action handler) + { + var key = (trigger, countSpec); + if (!_spatial.TryGetValue(key, out var list)) + { + list = []; + _spatial[key] = list; + } + list.Add(handler); + } + + public void AddNonSpatial(Type trigger, Action handler) + { + if (!_nonSpatial.TryGetValue(trigger, out var list)) + { + list = []; + _nonSpatial[trigger] = list; + } + list.Add(handler); + } + + public void Fire(Type trigger, int count) + { + // 1. AnyTap handlers (cualquier count). + if (_spatial.TryGetValue((trigger, typeof(Share.Input.AnyTap)), out var any)) + foreach (var handler in any.ToArray()) handler(); + + // 2. Conteo específico: OneTap..TenTap (mapear int -> Type). + if (count >= 1 && count <= TapCountTypes.Length) + { + var countType = TapCountTypes[count - 1]; + if (_spatial.TryGetValue((trigger, countType), out var specific)) + foreach (var handler in specific.ToArray()) handler(); + } + + // 3. NoTap handlers (Hover, Holding: triggers sin conteo). + if (_spatial.TryGetValue((trigger, typeof(Share.Input.NoTap)), out var noTap)) + foreach (var handler in noTap.ToArray()) handler(); + + // 4. Non-spatial (KeyMenu, Chord...). + if (_nonSpatial.TryGetValue(trigger, out var nonSpatial)) + foreach (var handler in nonSpatial.ToArray()) handler(); + } +} diff --git a/src/EasyWindowsApplication/Core/Surfaces/TapChainCounter.cs b/src/EasyWindowsApplication/Core/Surfaces/TapChainCounter.cs new file mode 100644 index 0000000..52d1ac0 --- /dev/null +++ b/src/EasyWindowsApplication/Core/Surfaces/TapChainCounter.cs @@ -0,0 +1,42 @@ +using EasyWindowsApplication.Share.Input; + +namespace EasyWindowsApplication.Core.Surfaces; + +/// +/// Contador de taps encadenados extraído de SystemTrayBroker.Fire/IsChainable. +/// Una sola fuente de verdad de timing para el broker del tray y futuros adaptadores. +/// +internal sealed class TapChainCounter +{ + private Type? _lastTrigger; + private int _lastCount; + private int _lastTick; + + public int Next(Type trigger, int nowTick, uint doubleClickTimeMs) + { + int count = 1; + if (IsChainable(trigger) && trigger == _lastTrigger) + { + if (unchecked(nowTick - _lastTick) <= (int)doubleClickTimeMs) + count = Math.Min(_lastCount + 1, 10); + } + if (IsChainable(trigger)) + { + _lastTrigger = trigger; + _lastCount = count; + _lastTick = nowTick; + } + else + { + _lastTrigger = null; + _lastCount = 0; + } + return count; + } + + public static bool IsChainable(Type trigger) + => trigger == typeof(MainTap) + || trigger == typeof(AlternativeTap1) + || trigger == typeof(AlternativeTap2) + || trigger == typeof(LongTap); +} diff --git a/src/EasyWindowsApplication/Core/SystemTray/SystemTrayBroker.cs b/src/EasyWindowsApplication/Core/SystemTray/SystemTrayBroker.cs new file mode 100644 index 0000000..cfdc535 --- /dev/null +++ b/src/EasyWindowsApplication/Core/SystemTray/SystemTrayBroker.cs @@ -0,0 +1,263 @@ +using System.Runtime.InteropServices; +using EasyWindowsApplication.Core.Surfaces; +using EasyWindowsApplication.Share.Input; + +namespace EasyWindowsApplication.Core.SystemTray; + +internal sealed class SystemTrayBroker +{ + internal const nuint HoldTimerId = 1; + internal const uint HoldThresholdMs = 500; + internal const uint HoverResetMs = 1000; + internal const uint IconId = 1; + + private const uint WM_MOUSEMOVE = 0x0200; + private const uint WM_LBUTTONDOWN = 0x0201; + private const uint WM_LBUTTONUP = 0x0202; + private const uint WM_RBUTTONUP = 0x0205; + private const uint WM_MBUTTONUP = 0x0208; + + private readonly Action _dispatch; + private readonly uint _callbackMessage; + + private bool _iconAdded; + private string _tooltip = ""; + private nint _icon; + private bool _disposed; + + private int _lastHoverTick; + private bool _holding; + private readonly TapChainCounter _counter = new(); + + internal nint Hwnd { get; } + internal uint CallbackMessage => _callbackMessage; + + private SystemTrayBroker(nint hwnd, uint callbackMessage, Action dispatch) + { + Hwnd = hwnd; + _callbackMessage = callbackMessage; + _dispatch = dispatch; + } + + internal static SystemTrayBroker Create(MasterRouter router, Action dispatch) + { + nint hwnd = Windowing.Procedures.CreateHiddenWindow(router); + uint callback = Win32.RegisterWindowMessageW("EasyWindowsApplication.Tray.v1"); + if (callback == 0) + throw new InvalidOperationException("RegisterWindowMessageW failed for tray callback."); + var broker = new SystemTrayBroker(hwnd, callback, dispatch); + router.RegisterHandler(hwnd, callback, broker.OnCallback); + router.RegisterHandler(hwnd, WM.CONTEXTMENU, broker.OnContextMenu); + router.RegisterHandler(hwnd, WM.TIMER, broker.OnTimer); + router.RegisterHandler(hwnd, WM.DESTROY, broker.OnDestroy); + return broker; + } + + internal bool AddIcon(nint icon) + { + _icon = icon; + var data = NewData(NIF.MESSAGE | NIF.ICON | NIF.TIP | NIF.SHOWTIP); + CopyStrings(ref data, _tooltip, null, null); + if (!Win32.Shell_NotifyIconW(NIM.ADD, ref data)) return false; + _iconAdded = true; + data.uVersion = NOTIFYICON.VERSION_4; + Win32.Shell_NotifyIconW(NIM.SETVERSION, ref data); + return true; + } + + internal void RemoveIcon() + { + if (!_iconAdded) return; + var data = NewData(0); + Win32.Shell_NotifyIconW(NIM.DELETE, ref data); + _iconAdded = false; + } + + internal void UpdateTooltip(string tooltip) + { + _tooltip = tooltip; + if (!_iconAdded) return; + var data = NewData(NIF.TIP | NIF.SHOWTIP); + CopyStrings(ref data, tooltip, null, null); + Win32.Shell_NotifyIconW(NIM.MODIFY, ref data); + } + + internal void ShowBalloon(string title, string text) + { + if (!_iconAdded) return; + var data = NewData(NIF.INFO); + CopyStrings(ref data, null, text, title); + Win32.Shell_NotifyIconW(NIM.MODIFY, ref data); + } + + internal void HideBalloon() + { + if (!_iconAdded) return; + var data = NewData(NIF.INFO); + Win32.Shell_NotifyIconW(NIM.MODIFY, ref data); + } + + internal void Shutdown() + { + if (_disposed) return; + _disposed = true; + Win32.KillTimer(Hwnd, HoldTimerId); + RemoveIcon(); + Windowing.Win32.DestroyWindow(Hwnd); + HandleRegistry.UnregisterRouter(Hwnd); + } + + internal void SetVisible(bool visible) + { + if (_disposed) return; + if (visible) + { + if (!_iconAdded) AddIcon(_icon); + } + else + { + RemoveIcon(); + } + } + + private nint OnCallback(nint wParam, nint lParam) + { + // VERSION_4 empaqueta lParam = mensaje (LOWORD) | uID del icono (HIWORD). + uint packed = (uint)lParam; + if ((packed >> 16) != IconId) return 0; + switch (packed & 0xFFFF) + { + case WM_MOUSEMOVE: OnHover(); break; + case WM_LBUTTONDOWN: OnButtonDown(); break; + case WM_LBUTTONUP: OnButtonUp(); break; + // WM_LBUTTONDBLCLK se ignora a propósito: el doble-tap ya se codifica + // como cadena MainTap + TwoTap en el segundo WM_LBUTTONUP (§4.2). + case WM_RBUTTONUP: if (IsCursorOverIcon()) Fire(typeof(AlternativeTap1)); break; + case WM_MBUTTONUP: if (IsCursorOverIcon()) Fire(typeof(AlternativeTap2)); break; + case WM.CONTEXTMENU: OnContextMenuKey(); break; + case var m when m == NIN.SELECT || m == NIN.KEYSELECT: Fire(typeof(MainTap)); break; + case var m when m == NIN.POPUPOPEN: OnHover(); break; + } + return 0; + } + + private nint OnContextMenu(nint wParam, nint lParam) + { + if (lParam != -1) return 0; + Fire(typeof(KeyMenu)); + if (Win32.GetKeyState(VK.SHIFT) < 0) + _dispatch(typeof(Chord), 1); + return 0; + } + + private void OnContextMenuKey() + { + // WM_CONTEXTMENU por callback llega igual con ratón y con teclado. + // Heurística: el click de ratón exige el cursor sobre el icono; + // la tecla de menú funciona con el cursor en cualquier parte. + if (IsCursorOverIcon()) + { + Fire(typeof(AlternativeTap1)); + return; + } + Fire(typeof(KeyMenu)); + if (Win32.GetKeyState(VK.SHIFT) < 0) + _dispatch(typeof(Chord), 1); + } + + private bool IsCursorOverIcon() + { + if (!Win32.GetCursorPos(out var pt)) return false; + var identifier = new NOTIFYICONIDENTIFIER + { + cbSize = (uint)Marshal.SizeOf(), + hWnd = Hwnd, + uID = IconId, + guidItem = Guid.Empty + }; + if (Win32.Shell_NotifyIconGetRect(ref identifier, out var rect) != 0) + return false; + return pt.X >= rect.Left && pt.X < rect.Right && pt.Y >= rect.Top && pt.Y < rect.Bottom; + } + + private nint OnTimer(nint wParam, nint lParam) + { + if ((nuint)wParam != HoldTimerId) return 0; + Win32.KillTimer(Hwnd, HoldTimerId); + _holding = true; + Fire(typeof(Holding)); + return 0; + } + + private nint OnDestroy(nint wParam, nint lParam) + { + Win32.KillTimer(Hwnd, HoldTimerId); + RemoveIcon(); + HandleRegistry.UnregisterRouter(Hwnd); + return 0; + } + + private void OnButtonDown() + { + _holding = false; + Win32.SetTimer(Hwnd, HoldTimerId, HoldThresholdMs, 0); + } + + private void OnButtonUp() + { + Win32.KillTimer(Hwnd, HoldTimerId); + if (_holding) + { + _holding = false; + Fire(typeof(LongTap)); + } + else + { + Fire(typeof(MainTap)); + } + } + + private void OnHover() + { + int now = Environment.TickCount; + if (_lastHoverTick != 0 && unchecked(now - _lastHoverTick) < (int)HoverResetMs) return; + _lastHoverTick = now; + Fire(typeof(Hover)); + } + + private void Fire(Type trigger) + { + int count = _counter.Next(trigger, Environment.TickCount, Win32.GetDoubleClickTime()); + _dispatch(trigger, count); + } + + private NOTIFYICONDATAW NewData(uint flags) + { + return new NOTIFYICONDATAW + { + cbSize = (uint)Marshal.SizeOf(), + hWnd = Hwnd, + uID = IconId, + uFlags = flags, + uCallbackMessage = _callbackMessage, + hIcon = _icon + }; + } + + private static unsafe void CopyStrings(ref NOTIFYICONDATAW data, string? tip, string? info, string? infoTitle) + { + fixed (NOTIFYICONDATAW* p = &data) + { + if (tip is not null) CopyText(p->szTip, 128, tip); + if (info is not null) CopyText(p->szInfo, 256, info); + if (infoTitle is not null) CopyText(p->szInfoTitle, 64, infoTitle); + } + } + + private static unsafe void CopyText(char* dst, int capacity, string text) + { + int n = Math.Min(text.Length, capacity - 1); + for (int i = 0; i < n; i++) dst[i] = text[i]; + dst[n] = '\0'; + } +} diff --git a/src/EasyWindowsApplication/Core/SystemTray/SystemTrayImpl.cs b/src/EasyWindowsApplication/Core/SystemTray/SystemTrayImpl.cs new file mode 100644 index 0000000..0d8e53c --- /dev/null +++ b/src/EasyWindowsApplication/Core/SystemTray/SystemTrayImpl.cs @@ -0,0 +1,74 @@ +using EasyWindowsApplication.Core.Surfaces; +using EasyWindowsApplication.Share; +using EasyWindowsApplication.Share.Input; + +namespace EasyWindowsApplication.Core.SystemTray; + +internal sealed class SystemTrayImpl : ISystemTray +{ + private readonly SystemTrayBroker _broker; + private readonly SurfaceInputHub _hub = new(); + private string _tooltip = ""; + + internal SystemTrayBroker Broker => _broker; + + internal SystemTrayImpl(SystemTrayBroker broker) => _broker = broker; + + public string Name { get; set; } = "SystemTray"; + public LayoutLength? LayoutWidth { get; set; } + public LayoutLength? LayoutHeight { get; set; } + public LayoutOptions LayoutOptions { get; set; } = new(); + public Thickness Margin { get; set; } + public Thickness Padding { get; set; } + public DockPosition Dock { get; set; } + public int GridRow { get; set; } + public int GridColumn { get; set; } + public int GridRowSpan { get; set; } + public int GridColumnSpan { get; set; } + public Color? BackgroundColor { get; set; } + + public void Visibility(bool visible) => _broker.SetVisible(visible); + + public ISystemTray Tooltip(string text) + { + _tooltip = text; + _broker.UpdateTooltip(text); + return this; + } + + public ISystemTray Notify(string title, string message) + { + _broker.ShowBalloon(title, message); + return this; + } + + public ISystemTray DismissNotification() + { + _broker.HideBalloon(); + return this; + } + + public void OnInputWithSpatialPosition(Action handler) + where TTrigger : ISpatialPositionTrigger + { + OnInputWithSpatialPosition(handler); + } + + public void OnInputWithSpatialPosition(Action handler) + where TTrigger : ISpatialPositionTrigger + where TCount : ITapCountSpec + { + _hub.AddSpatial(typeof(TTrigger), typeof(TCount), handler); + } + + public void OnInputWithSpatialPosition(Action handler) + => OnInputWithSpatialPosition(handler); + + public void OnInputWithoutSpatialPosition(Action handler) + where TTrigger : IWithoutSpatialPositionTrigger + { + _hub.AddNonSpatial(typeof(TTrigger), handler); + } + + internal void DispatchTrigger(Type trigger, int count) => _hub.Fire(trigger, count); +} diff --git a/src/EasyWindowsApplication/Core/TaskbarList.cs b/src/EasyWindowsApplication/Core/TaskbarList.cs new file mode 100644 index 0000000..7a0d873 --- /dev/null +++ b/src/EasyWindowsApplication/Core/TaskbarList.cs @@ -0,0 +1,45 @@ +using System.Runtime.InteropServices; + +namespace EasyWindowsApplication.Core; + +internal static partial class TaskbarList +{ + [LibraryImport("ole32.dll")] + private static partial int CoCreateInstance( + ref Guid rclsid, nint pUnkOuter, uint dwClsContext, ref Guid riid, out nint ppv); + + // ITaskbarList vtable: 0 QueryInterface, 1 AddRef, 2 Release, + // 3 HrInit, 4 AddTab, 5 DeleteTab, 6 ActivateTab, 7 SetActiveAlt. + // Llamada manual (sin ComImport: el dispatch clásico lanza + // NotSupportedException en .NET moderno). AOT-safe. + internal static unsafe bool SetVisible(nint hwnd, bool visible) + { + if (hwnd == 0) return false; + try + { + var clsid = new Guid("56FDF344-FD6D-11D0-958A-006097C9A090"); + var iid = new Guid("56FDF342-FD6D-11D0-958A-006097C9A090"); + int hr = CoCreateInstance(ref clsid, 0, 1, ref iid, out nint ppv); + if (hr != 0 || ppv == 0) return false; + try + { + nint* vtable = *(nint**)ppv; + var hrInit = (delegate* unmanaged[Stdcall])vtable[3]; + var addTab = (delegate* unmanaged[Stdcall])vtable[4]; + var deleteTab = (delegate* unmanaged[Stdcall])vtable[5]; + if (hrInit(ppv) != 0) return false; + int result = visible ? addTab(ppv, hwnd) : deleteTab(ppv, hwnd); + return result == 0; + } + finally + { + var release = (delegate* unmanaged[Stdcall])((*(nint**)ppv)[2]); + release(ppv); + } + } + catch + { + return false; + } + } +} diff --git a/src/EasyWindowsApplication/Core/Threading/UiApartment.cs b/src/EasyWindowsApplication/Core/Threading/UiApartment.cs new file mode 100644 index 0000000..0e1f4ca --- /dev/null +++ b/src/EasyWindowsApplication/Core/Threading/UiApartment.cs @@ -0,0 +1,36 @@ +using System.Threading; + +namespace EasyWindowsApplication.Core.Threading; + +// Fontanería interna de apartment COM. Los diálogos del shell +// (IFileOpenDialog / IFileSaveDialog / IFileDialog con FOS_PICKFOLDERS) +// requieren un hilo STA, y el hilo de entrada de .NET es MTA por defecto +// (Thread.ApartmentState: el main thread arranca en MTA). El apartment COM +// es inmutable, así que CoInitializeEx(STA) sobre MTA devolvería +// RPC_E_CHANGED_MODE; la única puerta es ejecutar el pipeline en un +// Thread STA propio ("EWA-UI"). +// +// Esto es especialmente importante porque el framework no usa ni Task, ni +// Dispatcher, ni SynchronizationContext: no hay nada más que capture el +// hilo de UI, y todos los HWND se crean aquí mismo, así que diálogo y +// dueño viven siempre en el mismo hilo (cero cross-thread). +internal static class UiApartment +{ + // Si el llamante ya está en STA (p. ej. Program.Main con [STAThread]), + // ejecuta el body en el mismo hilo y no se crea ningún hilo extra. + // En caso contrario crea un Thread STA, ejecuta el body ahí y espera + // con Join() hasta que el body (que contiene el message loop) termine. + internal static void RunStaOrCurrent(Action body) + { + if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) + { + body(); + return; + } + + var ui = new Thread(new ThreadStart(body)) { Name = "EWA-UI", IsBackground = false }; + ui.SetApartmentState(ApartmentState.STA); + ui.Start(); + ui.Join(); + } +} \ No newline at end of file diff --git a/src/EasyWindowsApplication/Core/Win32.cs b/src/EasyWindowsApplication/Core/Win32.cs index 15147cd..519916e 100644 --- a/src/EasyWindowsApplication/Core/Win32.cs +++ b/src/EasyWindowsApplication/Core/Win32.cs @@ -108,6 +108,33 @@ internal static partial class Win32 [LibraryImport("kernel32.dll", StringMarshalling = StringMarshalling.Utf16)] internal static partial nint GetModuleHandleW(nint lpModuleName); + // ── Window layering / DWM / display affinity (Phase C — #47) ── + [LibraryImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetLayeredWindowAttributes(nint hwnd, uint crKey, byte bAlpha, uint dwFlags); + + [LibraryImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetWindowDisplayAffinity(nint hWnd, uint dwAffinity); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool IsZoomed(nint hWnd); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool IsIconic(nint hWnd); + + [LibraryImport("dwmapi.dll")] + internal static partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, ref int pvAttribute, int cbAttribute); + + [LibraryImport("dwmapi.dll")] + internal static partial int DwmExtendFrameIntoClientArea(nint hwnd, ref MARGINS pMarInset); + + /// Shell AppBar message (taskbar state). shell32.dll. + [LibraryImport("shell32.dll")] + internal static partial nint SHAppBarMessage(uint dwMessage, ref APPBARDATA pData); + // ── GDI ── [LibraryImport("gdi32.dll")] internal static partial nint CreateSolidBrush(int color); @@ -180,6 +207,37 @@ internal static partial class Win32 [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool ScreenToClient(nint hWnd, ref POINT lpPoint); + // ── Pure helpers ── + + /// Detector Win10 1809+ (para DwmSetWindowAttribute/DWMSBT). + internal static bool IsWindows10_1809OrLater() + => Environment.OSVersion.Version is { Major: >= 10, Build: >= 17763 }; + + /// + /// Bounds de FullScreen taskbar-aware (#56): + /// + /// taskbar visible (no auto-hide) → rcWork (respeta el espacio de taskbar/appbars de cualquier borde). + /// taskbar en auto-hide → rcMonitor (cubre todo; aparece por hover encima sin bloquear). + /// + /// Multi-monitor y otras appbars acopladas quedan cubiertas por delegación en MONITORINFO.rcWork. + /// + internal static RECT ResolveFullScreenBounds(nint hwnd) + { + nint mon = MonitorFromWindow(hwnd, MONITOR.DEFAULTTONEAREST); + if (mon == 0) mon = MonitorFromWindow(hwnd, MONITOR.DEFAULTTOPRIMARY); + if (mon == 0) return default; + + MONITORINFO mi = new() { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + if (!GetMonitorInfoW(mon, ref mi)) return mi.rcWork; + + var abd = new APPBARDATA { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + nint st = SHAppBarMessage(ABM.GETSTATE, ref abd); + bool autoHide = ((ulong)st & ABS.AUTOHIDE) != 0; + + // auto-hide: la taskbar no reclama espacio permanente → la ventana puede cubrirla. + return autoHide ? mi.rcMonitor : mi.rcWork; + } + // ── ComCtl32 ── [LibraryImport("comctl32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] @@ -215,4 +273,60 @@ internal static partial class Win32 [LibraryImport("user32.dll", EntryPoint = "SetWindowTextW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool SetWindowText(nint hWnd, string lpString); + + // ── Window Messages ── + [LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)] + internal static partial uint RegisterWindowMessageW(string lpString); + + // ── Timers ── + [LibraryImport("user32.dll")] + internal static partial nuint SetTimer(nint hWnd, nuint nIDEvent, uint uElapse, nint lpTimerFunc); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool KillTimer(nint hWnd, nuint uIDEvent); + + // ── Keyboard state ── + [LibraryImport("user32.dll")] + internal static partial short GetKeyState(int nVirtKey); + + [LibraryImport("user32.dll")] + internal static partial uint GetDoubleClickTime(); + + // ── Menus (HMENU) ── + [LibraryImport("user32.dll")] + internal static partial nint CreatePopupMenu(); + + [LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool AppendMenuW(nint hMenu, uint uFlags, nuint uIDNewItem, string? lpNewItem); + + [LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool InsertMenuW(nint hMenu, uint uPosition, uint uFlags, nuint uIDNewItem, string? lpNewItem); + + [LibraryImport("user32.dll")] + internal static partial int TrackPopupMenuEx(nint hMenu, uint uFlags, int x, int y, nint hwnd, nint lptpm); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool DestroyMenu(nint hMenu); + + [LibraryImport("user32.dll")] + internal static partial int GetMenuItemCount(nint hMenu); + + [LibraryImport("user32.dll")] + internal static partial uint GetMenuState(nint hMenu, uint uId, uint uFlags); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetCursorPos(out POINT lpPoint); + + // ── Shell NotifyIcon ── + [LibraryImport("shell32.dll", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool Shell_NotifyIconW(uint dwMessage, ref NOTIFYICONDATAW lpData); + + [LibraryImport("shell32.dll")] + internal static partial int Shell_NotifyIconGetRect(ref NOTIFYICONIDENTIFIER identifier, out RECT iconLocation); } diff --git a/src/EasyWindowsApplication/Core/Windowing/AlternativeWindowImpl.cs b/src/EasyWindowsApplication/Core/Windowing/AlternativeWindowImpl.cs index ee1a5ee..07c3e08 100644 --- a/src/EasyWindowsApplication/Core/Windowing/AlternativeWindowImpl.cs +++ b/src/EasyWindowsApplication/Core/Windowing/AlternativeWindowImpl.cs @@ -1,14 +1,33 @@ using EasyWindowsApplication.Common; using EasyWindowsApplication.Core; using EasyWindowsApplication.Core.LayoutEngine; +using EasyWindowsApplication.Core.Surfaces; using EasyWindowsApplication.Share; +using EasyWindowsApplication.Share.Input; using EasyWindowsApplication.Win32ControlsModule.Frontend; namespace EasyWindowsApplication.Core.Windowing; -internal sealed class AlternativeWindowImpl : IAlternativeWindow +internal sealed class AlternativeWindowImpl : IAlternativeWindow, IInputFeed { - public nint Hwnd { get; } + private readonly SurfaceInputHub _hub = new(); + void IInputFeed.FeedTrigger(Type trigger, int count) => _hub.Fire(trigger, count); + + public void OnInputWithSpatialPosition(Action handler) where TTrigger : ISpatialPositionTrigger + => OnInputWithSpatialPosition(handler); + + public void OnInputWithSpatialPosition(Action handler) + where TTrigger : ISpatialPositionTrigger + where TCount : ITapCountSpec + => _hub.AddSpatial(typeof(TTrigger), typeof(TCount), handler); + + public void OnInputWithSpatialPosition(Action handler) + => OnInputWithSpatialPosition(handler); + + public void OnInputWithoutSpatialPosition(Action handler) where TTrigger : IWithoutSpatialPositionTrigger + => _hub.AddNonSpatial(typeof(TTrigger), handler); + + public nint Hwnd { get; private set; } public string Name { get; } public nint OwnerHwnd { get; } private string _title; @@ -18,7 +37,7 @@ public string Title set { _title = value ?? ""; - if (Hwnd != 0) EasyWindowsApplication.Core.Win32.SetWindowText(Hwnd, _title); + if (Hwnd != 0) Core.Win32.SetWindowText(Hwnd, _title); } } private int _width; @@ -30,7 +49,7 @@ public int Width if (_width == value) return; _width = value; if (Hwnd != 0) - EasyWindowsApplication.Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, _width, _height, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE); + Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, _width, _height, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE); } } private int _height; @@ -42,7 +61,7 @@ public int Height if (_height == value) return; _height = value; if (Hwnd != 0) - EasyWindowsApplication.Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, _width, _height, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE); + Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, _width, _height, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE); } } private WindowPositionOnScreen _positionMode; @@ -56,21 +75,21 @@ public WindowPositionOnScreen PositionMode if (Hwnd != 0 && _positionMode == WindowPositionOnScreen.Center) { // Reuse Center logic similar to WindowImpl - nint monitor = EasyWindowsApplication.Core.Win32.MonitorFromWindow(Hwnd, MONITOR.DEFAULTTONEAREST); - if (monitor == 0) monitor = EasyWindowsApplication.Core.Win32.MonitorFromWindow(Hwnd, MONITOR.DEFAULTTOPRIMARY); + nint monitor = Core.Win32.MonitorFromWindow(Hwnd, MONITOR.DEFAULTTONEAREST); + if (monitor == 0) monitor = Core.Win32.MonitorFromWindow(Hwnd, MONITOR.DEFAULTTOPRIMARY); if (monitor != 0) { MONITORINFO mi = new() { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; - if (EasyWindowsApplication.Core.Win32.GetMonitorInfoW(monitor, ref mi)) + if (Core.Win32.GetMonitorInfoW(monitor, ref mi)) { - EasyWindowsApplication.Core.Win32.GetWindowRect(Hwnd, out RECT wr); + Core.Win32.GetWindowRect(Hwnd, out RECT wr); int winW = wr.Right - wr.Left; int winH = wr.Bottom - wr.Top; int workW = mi.rcWork.Right - mi.rcWork.Left; int workH = mi.rcWork.Bottom - mi.rcWork.Top; int x = mi.rcWork.Left + (workW - winW) / 2; int y = mi.rcWork.Top + (workH - winH) / 2; - EasyWindowsApplication.Core.Win32.SetWindowPos(Hwnd, 0, x, y, 0, 0, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOSIZE); + Core.Win32.SetWindowPos(Hwnd, 0, x, y, 0, 0, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOSIZE); } } } @@ -80,6 +99,12 @@ public WindowPositionOnScreen PositionMode private List? _materializedChildren; private ContentModel? _contentModel; private bool _hasLoaded; + private bool _destroyed; + private bool _tearingDown; + + private WindowModel? _model; + private HandleRegistry? _registry; + private MasterRouter? _router; public event EventHandler? Loaded; public event EventHandler? Closing; @@ -87,6 +112,13 @@ public WindowPositionOnScreen PositionMode public event EventHandler? Activated; public event EventHandler? Deactivated; + // Behavior-first lifecycle (inherited from IBaseWindow + IAlternativeWindow) + public void OnLoaded(Action handler) => Loaded += (_, _) => handler(); + public void OnActivated(Action handler) => Activated += (_, _) => handler(); + public void OnDeactivated(Action handler) => Deactivated += (_, _) => handler(); + public void OnClosing(Action handler) => Closing += (_, e) => handler(e); + public void OnClosed(Action handler) => Closed += (_, _) => handler(); + internal AlternativeWindowImpl(nint hwnd, nint ownerHwnd, string name, string title, int width, int height, WindowPositionOnScreen position) { Hwnd = hwnd; @@ -100,15 +132,103 @@ internal AlternativeWindowImpl(nint hwnd, nint ownerHwnd, string name, string ti public void Show() { - EasyWindowsApplication.Core.Windowing.Win32.ShowWindow(Hwnd, SW.SHOW); + if (_destroyed) + EnsureCreated(); + if (Hwnd == 0) + return; + Win32.ShowWindow(Hwnd, SW.SHOW); if (!_hasLoaded) { _hasLoaded = true; Loaded?.Invoke(this, EventArgs.Empty); } } - public void Hide() => EasyWindowsApplication.Core.Windowing.Win32.ShowWindow(Hwnd, SW.HIDE); - public void Close() => EasyWindowsApplication.Core.Windowing.Win32.DestroyWindow(Hwnd); + public void Hide() + { + if (Hwnd == 0 || _destroyed) + return; + Win32.ShowWindow(Hwnd, SW.HIDE); + } + + public void Close() + { + if (_destroyed || Hwnd == 0) + return; + if (RaiseClosing()) + return; + Win32.DestroyWindow(Hwnd); + // WM_DESTROY → MarkDestroyed() completa la transición. + } + + public void Visibility(bool visible) + { + if (visible) Show(); + else Hide(); + } + + /// + /// Retiene el modelo y servicios para la re-creación a demanda. + /// Llamado por Application.RegisterAlternative. + /// + internal void BindForRecreation(WindowModel model, HandleRegistry registry, MasterRouter router) + { + _model = model; + _registry = registry; + _router = router; + } + + internal void MarkDestroyed() + { + Hwnd = 0; + _destroyed = true; + _hasLoaded = false; + _materializedChildren = null; + _contentModel = null; + } + + private void EnsureCreated() + { + if (!_destroyed) + return; + if (_model is null || _registry is null || _router is null) + throw new InvalidOperationException($"AlternativeWindow '{Name}' destruida sin modelo para re-crear."); + // Las instancias de controles se REUTILIZAN (no se resetea vm.Control): + // MaterializeChildren re-corre factory.CreateHandle sobre la misma + // instancia (re-asigna Hwnd + re-registra) y así sobreviven las + // suscripciones del Behavior (SurfaceInputHub) y el estado runtime. + // Solo se destruyó lo caro (los HWND). + nint hwnd = Procedures.CreateAlternativeWindow(_router, OwnerHwnd, _title, _width, _height); + // DEFENSA: limpiar posible estado residual si el SO reutilizó el hwnd instantáneamente + _router.CleanupHwnd(hwnd); + Hwnd = hwnd; + if (_model.Background.HasValue) + { + nint brush = Core.Win32.CreateSolidBrush(_model.Background.Value.ToCOLORREF()); + _router.RegisterWindowBackgroundBrush(hwnd, brush); + } + _registry.RegisterWindow(this); + MaterializeContent(_model, _registry, _router); + if (_positionMode == WindowPositionOnScreen.Center) + CenterCurrent(); + _destroyed = false; + } + + private void CenterCurrent() + { + if (Hwnd == 0) + return; + nint monitor = Core.Win32.MonitorFromWindow(Hwnd, MONITOR.DEFAULTTONEAREST); + if (monitor == 0) monitor = Core.Win32.MonitorFromWindow(Hwnd, MONITOR.DEFAULTTOPRIMARY); + if (monitor == 0) return; + MONITORINFO mi = new() { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + if (!Core.Win32.GetMonitorInfoW(monitor, ref mi)) return; + Core.Win32.GetWindowRect(Hwnd, out RECT wr); + int winW = wr.Right - wr.Left; + int winH = wr.Bottom - wr.Top; + int x = mi.rcWork.Left + ((mi.rcWork.Right - mi.rcWork.Left - winW) / 2); + int y = mi.rcWork.Top + ((mi.rcWork.Bottom - mi.rcWork.Top - winH) / 2); + Core.Win32.SetWindowPos(Hwnd, 0, x, y, 0, 0, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOSIZE); + } internal void RaiseLoaded() { @@ -116,13 +236,24 @@ internal void RaiseLoaded() _hasLoaded = true; Loaded?.Invoke(this, EventArgs.Empty); } + /// + /// Marca que la app está terminando. Closing/Closed no se disparan (teardown de app, + /// no cierre de ventana). Solo debe llamarse desde Application.RunPipeline's epílogo. + /// + internal void MarkTearingDown() => _tearingDown = true; + internal bool RaiseClosing() { + if (_tearingDown) return false; // app terminando: veto no aplica var args = new CancelEventArgs(); Closing?.Invoke(this, args); return args.Cancel; } - internal void RaiseClosed() => Closed?.Invoke(this, EventArgs.Empty); + internal void RaiseClosed() + { + if (_tearingDown) return; // app terminando: ningún callback de Behavior + Closed?.Invoke(this, EventArgs.Empty); + } internal void RaiseActivated() => Activated?.Invoke(this, EventArgs.Empty); internal void RaiseDeactivated() => Deactivated?.Invoke(this, EventArgs.Empty); @@ -142,7 +273,7 @@ internal void MaterializeContent(WindowModel window, HandleRegistry registry, Ma if (availW <= 0) availW = window.Width; if (availH <= 0) availH = window.Height; - var engine = new EasyWindowsApplication.Core.LayoutEngine.LayoutEngine(new VerticalStackLayoutStrategy()); + var engine = new LayoutEngine.LayoutEngine(new VerticalStackLayoutStrategy()); engine.Execute(layoutables, availW, availH, content.Spacing, content.Padding); _materializedChildren = layoutables; @@ -168,7 +299,11 @@ private static void MaterializeChildren(ContentModel model, nint parentHwnd, Han vm.Control = control; } - if (!ControlActivatorRegistry.Shared.TryGetFactoryForControl(control, out var factory) || factory is null) + // Las superficies sin HWND (IMenu, IMenuItem) no se materializan aquí; + // las materializa MenuSurface (Fase 1/2). + if (control is not IControl nativeControl) continue; + + if (!ControlActivatorRegistry.Shared.TryGetFactoryForControl(nativeControl, out var factory) || factory is null) { if (vm.ControlType is not null && ControlActivatorRegistry.Shared.TryGetFactory(vm.ControlType) is { } fallback) factory = fallback; @@ -176,7 +311,7 @@ private static void MaterializeChildren(ContentModel model, nint parentHwnd, Han throw new InvalidOperationException($"No handle factory registered for control '{control.GetType().Name}' (name='{control.Name}')."); } - nint hwnd = factory.CreateHandle(parentHwnd, control, registry); + nint hwnd = factory.CreateHandle(parentHwnd, nativeControl, registry); if (hwnd == 0) throw new InvalidOperationException($"CreateHandle failed for control '{control.Name}' ({control.GetType().Name})"); @@ -196,6 +331,9 @@ private static void MaterializeChildren(ContentModel model, nint parentHwnd, Han MaterializeChildren(sub, hwnd, registry, router, subLayoutables); if (control is UserControl uc) { + // La instancia se reutiliza entre re-creaciones: limpiar la + // agregación antes de re-añadir para no duplicar hijos. + uc.Children.Clear(); foreach (var subChild in subLayoutables) uc.Children.Add(subChild); uc.Spacing = sub.Spacing; } diff --git a/src/EasyWindowsApplication/Core/Windowing/Procedures.cs b/src/EasyWindowsApplication/Core/Windowing/Procedures.cs index 64ee4bc..8c14968 100644 --- a/src/EasyWindowsApplication/Core/Windowing/Procedures.cs +++ b/src/EasyWindowsApplication/Core/Windowing/Procedures.cs @@ -154,4 +154,67 @@ internal static unsafe nint CreateAlternativeWindow( return hwnd; } + + internal static nint LoadAppIcon() + { + nint hInstance = Win32.GetModuleHandleW(0); + int iconResId = ResolveIconResourceId(hInstance); + nint hIcon = Win32.LoadImageW( + hInstance, (nint)iconResId, IMAGE.ICON, + 0, 0, + LR.DEFAULTSIZE | LR.CREATEDIBSECTION); + if (hIcon == 0) + hIcon = Win32.LoadIconW(0, (nint)RT_MAINICON); + return hIcon; + } + + internal static unsafe nint CreateHiddenWindow(MasterRouter router) + { + nint hInstance = Win32.GetModuleHandleW(0); + + string className = $"EasyWinTray_{Guid.NewGuid():N}"; + nint classNamePtr = Marshal.StringToHGlobalUni(className); + + nint wndProcPtr = (nint)(delegate* unmanaged[Stdcall])&MasterRouter.WndProcTrampoline; + + var wndClass = new WNDCLASSEXW + { + cbSize = (uint)Marshal.SizeOf(), + style = CS.HREDRAW | CS.VREDRAW | CS.DBLCLKS, + lpfnWndProc = wndProcPtr, + cbClsExtra = 0, + cbWndExtra = 0, + hInstance = hInstance, + hIcon = 0, + hCursor = 0, + hbrBackground = 0, + lpszMenuName = 0, + lpszClassName = classNamePtr, + hIconSm = 0 + }; + + ushort atom = Win32.RegisterClassExW(ref wndClass); + if (atom == 0) + { + Marshal.FreeHGlobal(classNamePtr); + throw new InvalidOperationException($"RegisterClassExW failed: {Marshal.GetLastWin32Error()}"); + } + + nint titlePtr = Marshal.StringToHGlobalUni("EasyWinTray"); + nint hwnd = Win32.CreateWindowExW( + 0, classNamePtr, titlePtr, + WS.POPUP, + 0, 0, 0, 0, + 0, 0, hInstance, 0); + + Marshal.FreeHGlobal(classNamePtr); + Marshal.FreeHGlobal(titlePtr); + + if (hwnd == 0) + throw new InvalidOperationException($"CreateWindowExW failed: {Marshal.GetLastWin32Error()}"); + + HandleRegistry.RegisterRouter(hwnd, router); + + return hwnd; + } } diff --git a/src/EasyWindowsApplication/Core/Windowing/Win32.cs b/src/EasyWindowsApplication/Core/Windowing/Win32.cs index e8e49a3..8e0db7b 100644 --- a/src/EasyWindowsApplication/Core/Windowing/Win32.cs +++ b/src/EasyWindowsApplication/Core/Windowing/Win32.cs @@ -39,6 +39,9 @@ internal static partial nint CreateWindowExW( [LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] internal static partial nint LoadImageW(nint hInst, nint name, uint type, int cx, int cy, uint fuLoad); + [LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)] + internal static partial nint LoadIconW(nint hInstance, nint lpIconName); + [LibraryImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool GetClientRect(nint hWnd, out RECT lpRect); diff --git a/src/EasyWindowsApplication/Core/Windowing/WindowImpl.cs b/src/EasyWindowsApplication/Core/Windowing/WindowImpl.cs index 2db81da..bacace2 100644 --- a/src/EasyWindowsApplication/Core/Windowing/WindowImpl.cs +++ b/src/EasyWindowsApplication/Core/Windowing/WindowImpl.cs @@ -1,13 +1,31 @@ using EasyWindowsApplication.Common; -using EasyWindowsApplication.Core; using EasyWindowsApplication.Core.LayoutEngine; +using EasyWindowsApplication.Core.Surfaces; using EasyWindowsApplication.Share; +using EasyWindowsApplication.Share.Input; using EasyWindowsApplication.Win32ControlsModule.Frontend; namespace EasyWindowsApplication.Core.Windowing; -internal sealed class WindowImpl : IWindow +internal sealed class WindowImpl : IWindow, IInputFeed { + private readonly SurfaceInputHub _hub = new(); + void IInputFeed.FeedTrigger(Type trigger, int count) => _hub.Fire(trigger, count); + + public void OnInputWithSpatialPosition(Action handler) where TTrigger : ISpatialPositionTrigger + => OnInputWithSpatialPosition(handler); + + public void OnInputWithSpatialPosition(Action handler) + where TTrigger : ISpatialPositionTrigger + where TCount : ITapCountSpec + => _hub.AddSpatial(typeof(TTrigger), typeof(TCount), handler); + + public void OnInputWithSpatialPosition(Action handler) + => OnInputWithSpatialPosition(handler); + + public void OnInputWithoutSpatialPosition(Action handler) where TTrigger : IWithoutSpatialPositionTrigger + => _hub.AddNonSpatial(typeof(TTrigger), handler); + public nint Hwnd { get; } public string Name { get; } private string _title; @@ -17,7 +35,7 @@ public string Title set { _title = value ?? ""; - if (Hwnd != 0) EasyWindowsApplication.Core.Win32.SetWindowText(Hwnd, _title); + if (Hwnd != 0) Core.Win32.SetWindowText(Hwnd, _title); } } @@ -30,7 +48,7 @@ public float Width if (_width == value) return; _width = value; if (Hwnd != 0) - EasyWindowsApplication.Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, (int)_width, (int)_height, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE); + Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, (int)_width, (int)_height, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE); } } @@ -43,7 +61,7 @@ public float Height if (_height == value) return; _height = value; if (Hwnd != 0) - EasyWindowsApplication.Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, (int)_width, (int)_height, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE); + Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, (int)_width, (int)_height, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE); } } @@ -75,14 +93,20 @@ public WindowPositionOnScreen PositionMode public (int X, int Y) ScrollOffset => (_scrollX, _scrollY); public event EventHandler? Loaded; - public event EventHandler? Closing; - public event EventHandler? Closed; public event EventHandler? Activated; public event EventHandler? Deactivated; public event EventHandler? Resizing; public event EventHandler? Resized; public event EventHandler? Moved; + // Behavior-first lifecycle & geometry + public void OnLoaded(Action handler) => Loaded += (_, _) => handler(); + public void OnActivated(Action handler) => Activated += (_, _) => handler(); + public void OnDeactivated(Action handler) => Deactivated += (_, _) => handler(); + public void OnResizing(Action handler) => Resizing += (_, e) => handler(e); + public void OnResized(Action handler) => Resized += (_, e) => handler(e); + public void OnMoved(Action handler) => Moved += (_, e) => handler(e); + internal WindowImpl(nint hwnd, string name, string title, float width, float height, WindowPositionOnScreen position) { Hwnd = hwnd; @@ -93,46 +117,348 @@ internal WindowImpl(nint hwnd, string name, string title, float width, float hei _positionMode = position; } - public void Show() => EasyWindowsApplication.Core.Windowing.Win32.ShowWindow(Hwnd, SW.SHOW); - public void Hide() => EasyWindowsApplication.Core.Windowing.Win32.ShowWindow(Hwnd, SW.HIDE); - public void Close() => EasyWindowsApplication.Core.Windowing.Win32.DestroyWindow(Hwnd); + public void Show() => Win32.ShowWindow(Hwnd, SW.SHOW); + public void Hide() => Win32.ShowWindow(Hwnd, SW.HIDE); + public void Visibility(bool visible) + { + if (visible) Show(); + else Hide(); + } public void Center() { if (Hwnd == 0) return; - nint monitor = EasyWindowsApplication.Core.Win32.MonitorFromWindow(Hwnd, MONITOR.DEFAULTTONEAREST); - if (monitor == 0) monitor = EasyWindowsApplication.Core.Win32.MonitorFromWindow(Hwnd, MONITOR.DEFAULTTOPRIMARY); + nint monitor = Core.Win32.MonitorFromWindow(Hwnd, MONITOR.DEFAULTTONEAREST); + if (monitor == 0) monitor = Core.Win32.MonitorFromWindow(Hwnd, MONITOR.DEFAULTTOPRIMARY); if (monitor == 0) return; MONITORINFO mi = new() { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; - if (!EasyWindowsApplication.Core.Win32.GetMonitorInfoW(monitor, ref mi)) return; + if (!Core.Win32.GetMonitorInfoW(monitor, ref mi)) return; - EasyWindowsApplication.Core.Win32.GetWindowRect(Hwnd, out RECT wr); - int winW = wr.Right - wr.Left; - int winH = wr.Bottom - wr.Top; + Core.Win32.GetWindowRect(Hwnd, out RECT wr); + float winW = wr.Right - wr.Left; + float winH = wr.Bottom - wr.Top; - int workW = mi.rcWork.Right - mi.rcWork.Left; - int workH = mi.rcWork.Bottom - mi.rcWork.Top; + float workW = mi.rcWork.Right - mi.rcWork.Left; + float workH = mi.rcWork.Bottom - mi.rcWork.Top; - int x = mi.rcWork.Left + (workW - winW) / 2; - int y = mi.rcWork.Top + (workH - winH) / 2; + int x = (int)(mi.rcWork.Left + (workW - winW) / 2); + int y = (int)(mi.rcWork.Top + (workH - winH) / 2); - EasyWindowsApplication.Core.Win32.SetWindowPos(Hwnd, 0, x, y, 0, 0, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOSIZE); + Core.Win32.SetWindowPos(Hwnd, 0, x, y, 0, 0, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOSIZE); } - public void Maximize() => EasyWindowsApplication.Core.Win32.ShowWindow(Hwnd, SW.MAXIMIZE); - public void Minimize() => EasyWindowsApplication.Core.Win32.ShowWindow(Hwnd, SW.MINIMIZE); - public void Restore() => EasyWindowsApplication.Core.Win32.ShowWindow(Hwnd, SW.RESTORE); - public void Focus() => EasyWindowsApplication.Core.Win32.SetForegroundWindow(Hwnd); + public void Maximize() => Core.Win32.ShowWindow(Hwnd, SW.MAXIMIZE); + public void Minimize() => Core.Win32.ShowWindow(Hwnd, SW.MINIMIZE); + public void RestoreState() => Core.Win32.ShowWindow(Hwnd, SW.RESTORE); + public void Focus() => Core.Win32.SetForegroundWindow(Hwnd); - internal void RaiseLoaded() => Loaded?.Invoke(this, EventArgs.Empty); - internal bool RaiseClosing() + // ── Phase C — #47 ──────────────────────────────────────────────────────── + + private WindowState _state = WindowState.Normal; + public WindowState State { - var args = new CancelEventArgs(); - Closing?.Invoke(this, args); - return args.Cancel; + get => Core.Win32.IsZoomed(Hwnd) ? WindowState.Maximized + : Core.Win32.IsIconic(Hwnd) ? WindowState.Minimized + : _state == WindowState.FullScreen ? WindowState.FullScreen + : WindowState.Normal; + set + { + _state = value; + switch (value) + { + case WindowState.Normal: RestoreState(); break; + case WindowState.Minimized: Minimize(); break; + case WindowState.Maximized: Maximize(); break; + case WindowState.FullScreen: EnterFullScreen(); break; + } + } } - internal void RaiseClosed() => Closed?.Invoke(this, EventArgs.Empty); + + // FullScreen nativo: guarda estilos y rect original; restaura en ExitFullScreen. + private int _fsOrigStyle; + private int _fsOrigExStyle; + private RECT _fsOrigRect; + private bool _isFullScreen; + + public void EnterFullScreen() + { + if (Hwnd == 0 || _isFullScreen) return; + _isFullScreen = true; + _fsOrigStyle = Core.Win32.GetWindowLongW(Hwnd, GWL.STYLE); + _fsOrigExStyle = Core.Win32.GetWindowLongW(Hwnd, GWL.EXSTYLE); + Core.Win32.GetWindowRect(Hwnd, out _fsOrigRect); + int newStyle = _fsOrigStyle & ~(int)(WS.CAPTION | WS.THICKFRAME); + Core.Win32.SetWindowLongW(Hwnd, GWL.STYLE, newStyle); + // Bounds taskbar-aware (#56): rcWork si taskbar visible, rcMonitor si auto-hide. + RECT bounds = Core.Win32.ResolveFullScreenBounds(Hwnd); + if (bounds.Right > bounds.Left && bounds.Bottom > bounds.Top) + Core.Win32.SetWindowPos(Hwnd, 0, bounds.Left, bounds.Top, + bounds.Right - bounds.Left, bounds.Bottom - bounds.Top, + SWP.NOZORDER | SWP.NOACTIVATE | SWP.FRAMECHANGED); + _state = WindowState.FullScreen; + } + + public void ExitFullScreen() + { + if (Hwnd == 0 || !_isFullScreen) return; + _isFullScreen = false; + Core.Win32.SetWindowLongW(Hwnd, GWL.STYLE, _fsOrigStyle); + Core.Win32.SetWindowLongW(Hwnd, GWL.EXSTYLE, _fsOrigExStyle); + int w = _fsOrigRect.Right - _fsOrigRect.Left; + int h = _fsOrigRect.Bottom - _fsOrigRect.Top; + Core.Win32.SetWindowPos(Hwnd, 0, _fsOrigRect.Left, _fsOrigRect.Top, w, h, + SWP.NOZORDER | SWP.NOACTIVATE | SWP.FRAMECHANGED); + _state = WindowState.Normal; + } + + private bool _topMost; + public bool TopMost + { + get => _topMost; + set + { + if (_topMost == value) return; + _topMost = value; + // HWND_TOPMOST = -1; HWND_NOTOPMOST = -2 + Core.Win32.SetWindowPos(Hwnd, value ? -1 : -2, 0, 0, 0, 0, + SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE | SWP.NOSIZE); + } + } + + private double _opacity = 1.0; + public double Opacity + { + get => _opacity; + set + { + _opacity = Math.Clamp(value, 0.0, 1.0); + if (Hwnd == 0) return; + if (_opacity < 1.0) + { + int ex = Core.Win32.GetWindowLongW(Hwnd, GWL.EXSTYLE); + if ((ex & (int)WS_EX.LAYERED) == 0) + Core.Win32.SetWindowLongW(Hwnd, GWL.EXSTYLE, ex | (int)WS_EX.LAYERED); + Core.Win32.SetLayeredWindowAttributes(Hwnd, 0, (byte)Math.Round(_opacity * 255), LWA.ALPHA); + } + else + { + int ex = Core.Win32.GetWindowLongW(Hwnd, GWL.EXSTYLE); + if ((ex & (int)WS_EX.LAYERED) != 0) + Core.Win32.SetWindowLongW(Hwnd, GWL.EXSTYLE, ex & ~(int)WS_EX.LAYERED); + Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, 0, 0, + SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE | SWP.NOSIZE | SWP.FRAMECHANGED); + } + } + } + + private WindowBackdrop _backdrop = WindowBackdrop.None; + public WindowBackdrop Backdrop + { + get => _backdrop; + set + { + if (_backdrop == value) return; + _backdrop = value; + if (Hwnd == 0 || !Core.Win32.IsWindows10_1809OrLater()) return; + int type = value switch + { + WindowBackdrop.Mica => DWMSBT.MAINWINDOW, + WindowBackdrop.Acrylic or WindowBackdrop.DesktopAcrylic => DWMSBT.TRANSIENTWINDOW, + WindowBackdrop.SolidColor => DWMSBT.NONE, + _ => DWMSBT.AUTO, + }; + int hr = Core.Win32.DwmSetWindowAttribute(Hwnd, DWMWA.SYSTEMBACKDROP_TYPE, ref type, sizeof(int)); + System.Diagnostics.Debug.WriteLineIf(hr != 0, $"[EWA] DwmSetWindowAttribute(SYSTEMBACKDROP_TYPE={type}) falló: hr=0x{hr:X8}"); + } + } + + private ScreenCaptureMode _screenCapture = ScreenCaptureMode.Allow; + public ScreenCaptureMode ScreenCapture + { + get => _screenCapture; + set + { + if (_screenCapture == value) return; + _screenCapture = value; + if (Hwnd == 0) return; + uint affinity = value switch + { + ScreenCaptureMode.HideContent or ScreenCaptureMode.HideWindow => WDA.EXCLUDEFROMCAPTURE, + _ => WDA.NONE, + }; + if (!Core.Win32.SetWindowDisplayAffinity(Hwnd, affinity) && affinity == WDA.EXCLUDEFROMCAPTURE) + Core.Win32.SetWindowDisplayAffinity(Hwnd, WDA.MONITOR); // fallback Win10 < 2004 + } + } + + // Phase B: sizing constraints (el router los aplica en WM_GETMINMAXINFO) + private (int W, int H)? _minSize; + public (int MinWidth, int MinHeight) MinSize + { + get => _minSize ?? (0, 0); + set => _minSize = value; + } + + private (int W, int H)? _maxSize; + public (int MaxWidth, int MaxHeight) MaxSize + { + get => _maxSize ?? (0, 0); + set => _maxSize = value; + } + + private bool _resizable = true; + public bool Resizable + { + get => _resizable; + set + { + if (_resizable == value) return; + _resizable = value; + if (Hwnd == 0) return; + int style = Core.Win32.GetWindowLongW(Hwnd, GWL.STYLE); + int newStyle = value ? style | (int)WS.THICKFRAME : style & ~(int)WS.THICKFRAME; + if (newStyle != style) + { + Core.Win32.SetWindowLongW(Hwnd, GWL.STYLE, newStyle); + Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, 0, 0, + SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE | SWP.NOSIZE | SWP.FRAMECHANGED); + } + } + } + + private CaptionButtons _captionButtons = CaptionButtons.All; + public CaptionButtons CaptionButtons + { + get => _captionButtons; + set + { + if (_captionButtons == value) return; + _captionButtons = value; + if (Hwnd == 0) return; + int style = Core.Win32.GetWindowLongW(Hwnd, GWL.STYLE); + int newStyle = style; + if (value.HasFlag(CaptionButtons.Close)) newStyle |= (int)WS.SYSMENU; else newStyle &= ~(int)WS.SYSMENU; + if (value.HasFlag(CaptionButtons.Minimize)) newStyle |= (int)WS.MINIMIZEBOX; else newStyle &= ~(int)WS.MINIMIZEBOX; + if (value.HasFlag(CaptionButtons.Maximize)) newStyle |= (int)WS.MAXIMIZEBOX; else newStyle &= ~(int)WS.MAXIMIZEBOX; + if (newStyle != style) + { + Core.Win32.SetWindowLongW(Hwnd, GWL.STYLE, newStyle); + Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, 0, 0, + SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE | SWP.NOSIZE | SWP.FRAMECHANGED); + } + } + } + + // Phase D: Z-order + public void BringToFront() + { + if (Hwnd == 0) return; + // HWND_TOP = 0 + Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, 0, 0, SWP.NOMOVE | SWP.NOSIZE | SWP.NOACTIVATE); + } + + public void SendToBack() + { + if (Hwnd == 0) return; + // HWND_BOTTOM = 1 + Core.Win32.SetWindowPos(Hwnd, 1, 0, 0, 0, 0, SWP.NOMOVE | SWP.NOSIZE | SWP.NOACTIVATE); + } + + /// Aplica : MinSize/MaxSize desde Layout + MaximizedBounds del monitor. + internal void ApplyMinMaxInfo(ref MINMAXINFO info) + { + if (_minSize is { } ms) + { + info.ptMinTrackSize.X = ms.W; + info.ptMinTrackSize.Y = ms.H; + } + if (_maxSize is { } mx) + { + info.ptMaxTrackSize.X = mx.W; + info.ptMaxTrackSize.Y = mx.H; + } + // MaximizedBounds: área útil del monitor (taskbar excluida) + nint mon = Core.Win32.MonitorFromWindow(Hwnd, MONITOR.DEFAULTTONEAREST); + if (mon != 0) + { + MONITORINFO mi = new() { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + if (Core.Win32.GetMonitorInfoW(mon, ref mi)) + { + info.ptMaxSize.X = mi.rcWork.Right - mi.rcWork.Left; + info.ptMaxSize.Y = mi.rcWork.Bottom - mi.rcWork.Top; + info.ptMaxPosition.X = mi.rcWork.Left - mi.rcMonitor.Left; + info.ptMaxPosition.Y = mi.rcWork.Top - mi.rcMonitor.Top; + } + } + } + + /// Sincroniza desde SYSCOMMAND (pulsaciones del caption). + internal void SyncStateFromSysCommand(uint sysCmd) + { + _state = sysCmd switch + { + SC.MINIMIZE => WindowState.Minimized, + SC.MAXIMIZE => WindowState.Maximized, + SC.RESTORE => WindowState.Normal, + _ => _state, + }; + } + + /// Detección FullScreen por estilo (D3): sin CAPTION ni THICKFRAME + bounds == monitor. + internal void DetectFullScreenByStyle(int clientW, int clientH) + { + int style = Core.Win32.GetWindowLongW(Hwnd, GWL.STYLE); + bool borderless = (style & (int)(WS.CAPTION | WS.THICKFRAME)) == 0; + if (!borderless) return; + nint mon = Core.Win32.MonitorFromWindow(Hwnd, MONITOR.DEFAULTTONEAREST); + if (mon == 0) return; + MONITORINFO mi = new() { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + if (!Core.Win32.GetMonitorInfoW(mon, ref mi)) return; + int monW = mi.rcMonitor.Right - mi.rcMonitor.Left; + int monH = mi.rcMonitor.Bottom - mi.rcMonitor.Top; + if (clientW >= monW && clientH >= monH) + { + _isFullScreen = true; + _state = WindowState.FullScreen; + } + } + + // ── TitleBar (C4/C5) — #47 ──────────────────────────────────────────────── + + private bool _customTitleBar; + private TitleBarMode _titleBarMode = TitleBarMode.Simple; + internal bool CustomTitleBarActive => _customTitleBar; + internal bool TitleBarActive => _customTitleBar || _titleBarMode == TitleBarMode.Simple; + + /// Altura del área de caption custom en px lógicos (hit-test D5). + internal float CustomTitleBarHeight { get; set; } = 32f; + + /// Aplica el modo TitleBar: Simple (solo título/icono nativo) o Custom (frame extendido + hit-test). + internal void ApplyTitleBar(TitleBarBuilder? builder) + { + if (builder is null) return; + _titleBarMode = builder.Mode; + _customTitleBar = builder.Mode == TitleBarMode.Custom; + if (builder.Mode == TitleBarMode.Simple) + { + string? tbTitle = builder.Title; + if (!string.IsNullOrEmpty(tbTitle)) Title = tbTitle; + if (builder.Icon != 0) Core.Win32.SendMessageW(Hwnd, (uint)0x0080 /*WM.SETICON*/, 0, builder.Icon); + } + else + { + // Custom: extender frame al client area para pintar nuestra propia title bar (#47 C5). + var margins = new MARGINS { cxLeftWidth = -1, cxRightWidth = -1, cyTopHeight = -1, cyBottomHeight = -1 }; + Core.Win32.DwmExtendFrameIntoClientArea(Hwnd, ref margins); + } + } + + /// Hit-test custom caption (D5): si el punto está dentro del área del caption custom, devolvemos HTCAPTION. + internal bool IsCustomTitleBarHit(int clientX, int clientY) + => _customTitleBar && clientY >= 0 && clientY < CustomTitleBarHeight; + + internal void RaiseLoaded() => Loaded?.Invoke(this, EventArgs.Empty); internal void RaiseActivated() => Activated?.Invoke(this, EventArgs.Empty); internal void RaiseDeactivated() => Deactivated?.Invoke(this, EventArgs.Empty); public void ScrollTo(int x, int y) @@ -145,17 +471,17 @@ public void ScrollTo(int x, int y) _scrollY = newY; // Actualizar SCROLLINFO - if (_scrollConfig != null) + if (_scrollConfig is not null) { - if (_maxScrollY > 0 || _scrollConfig.VerticalScrollBarVisibility == ScrollBarVisibility.Always) + if (_maxScrollY > 0 || _scrollConfig.VerticalScrollBarVisibility is ScrollBarVisibility.Always) { var siV = new SCROLLINFO { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf(), fMask = SIF.POS, nPos = _scrollY }; - EasyWindowsApplication.Core.Win32.SetScrollInfo(Hwnd, 1, ref siV, true); + Core.Win32.SetScrollInfo(Hwnd, 1, ref siV, true); } - if (_maxScrollX > 0 || _scrollConfig.HorizontalScrollBarVisibility == ScrollBarVisibility.Always) + if (_maxScrollX > 0 || _scrollConfig.HorizontalScrollBarVisibility is ScrollBarVisibility.Always) { var siH = new SCROLLINFO { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf(), fMask = SIF.POS, nPos = _scrollX }; - EasyWindowsApplication.Core.Win32.SetScrollInfo(Hwnd, 0, ref siH, true); + Core.Win32.SetScrollInfo(Hwnd, 0, ref siH, true); } } @@ -169,8 +495,8 @@ public void ScrollTo(int x, int y) private void ApplyScrollOffset() { - if (_materializedChildren == null || _materializedChildren.Count == 0) return; - nint hdwp = EasyWindowsApplication.Core.Win32.BeginDeferWindowPos(_materializedChildren.Count); + if (_materializedChildren is null || _materializedChildren.Count == 0) return; + nint hdwp = Core.Win32.BeginDeferWindowPos(_materializedChildren.Count); bool batching = hdwp != 0; if (batching) { @@ -180,30 +506,31 @@ private void ApplyScrollOffset() { int x = (int)cb._arrangedX - _scrollX; int y = (int)cb._arrangedY - _scrollY; - nint next = EasyWindowsApplication.Core.Win32.DeferWindowPos(hdwp, cb.Hwnd, 0, x, y, (int)cb._arrangedW, (int)cb._arrangedH, SWP.NOZORDER | SWP.NOACTIVATE); + nint next = Core.Win32.DeferWindowPos(hdwp, cb.Hwnd, 0, x, y, (int)cb._arrangedW, (int)cb._arrangedH, SWP.NOZORDER | SWP.NOACTIVATE); if (next != 0) hdwp = next; else batching = false; } } if (batching) { - EasyWindowsApplication.Core.Win32.EndDeferWindowPos(hdwp); + Core.Win32.EndDeferWindowPos(hdwp); return; } - if (hdwp != 0) EasyWindowsApplication.Core.Win32.EndDeferWindowPos(hdwp); + if (hdwp != 0) Core.Win32.EndDeferWindowPos(hdwp); } foreach (var child in _materializedChildren) { if (child is ControlBase cb && cb.Hwnd != 0) { - EasyWindowsApplication.Core.Win32.SetWindowPos(cb.Hwnd, 0, (int)cb._arrangedX - _scrollX, (int)cb._arrangedY - _scrollY, (int)cb._arrangedW, (int)cb._arrangedH, SWP.NOZORDER | SWP.NOACTIVATE); + Core.Win32.SetWindowPos(cb.Hwnd, 0, (int)cb._arrangedX - _scrollX, (int)cb._arrangedY - _scrollY, (int)cb._arrangedW, (int)cb._arrangedH, SWP.NOZORDER | SWP.NOACTIVATE); } } } internal void HandleVScroll(int request, int thumbPos) { - if (_scrollConfig == null) return; + if (_scrollConfig is null) return; + int newPos = _scrollY; switch (request) { @@ -231,7 +558,7 @@ internal void HandleVScroll(int request, int thumbPos) internal void HandleHScroll(int request, int thumbPos) { - if (_scrollConfig == null) return; + if (_scrollConfig is null) return; int newPos = _scrollX; switch (request) { @@ -259,9 +586,9 @@ internal void HandleHScroll(int request, int thumbPos) internal void HandleMouseWheel(int delta) { - if (_scrollConfig == null) return; + if (_scrollConfig is null) return; // Rueda vertical por defecto; si orientación es Horizontal, scrollear horizontal - bool isHorizontal = _scrollConfig.Orientation == ScrollOrientation.Horizontal; + bool isHorizontal = _scrollConfig.Orientation is ScrollOrientation.Horizontal; if (isHorizontal) { int newX = _scrollX - delta * 30 / 120; @@ -276,12 +603,12 @@ internal void HandleMouseWheel(int delta) private void ConfigureScrollbars() { - if (_scrollConfig == null || Hwnd == 0) return; + if (_scrollConfig is null || Hwnd == 0) return; // Calcular tamaños totales del contenido int totalH = 0; int totalW = 0; - if (_materializedChildren != null && _materializedChildren.Count > 0) + if (_materializedChildren is not null && _materializedChildren.Count > 0) { // Altura total para VerticalStack foreach (var c in _materializedChildren) @@ -301,25 +628,25 @@ private void ConfigureScrollbars() if (wantsV) { needV = totalH > _clientH; - if (_scrollConfig.VerticalScrollBarVisibility == ScrollBarVisibility.Never) needV = false; - else if (_scrollConfig.VerticalScrollBarVisibility == ScrollBarVisibility.Always) needV = true; + if (_scrollConfig.VerticalScrollBarVisibility is ScrollBarVisibility.Never) needV = false; + else if (_scrollConfig.VerticalScrollBarVisibility is ScrollBarVisibility.Always) needV = true; } if (wantsH) { needH = totalW > _clientW; - if (_scrollConfig.HorizontalScrollBarVisibility == ScrollBarVisibility.Never) needH = false; - else if (_scrollConfig.HorizontalScrollBarVisibility == ScrollBarVisibility.Always) needH = true; + if (_scrollConfig.HorizontalScrollBarVisibility is ScrollBarVisibility.Never) needH = false; + else if (_scrollConfig.HorizontalScrollBarVisibility is ScrollBarVisibility.Always) needH = true; } // Activar estilos WS_HSCROLL/VSCROLL - int style = EasyWindowsApplication.Core.Win32.GetWindowLongW(Hwnd, GWL.STYLE); + int style = Core.Win32.GetWindowLongW(Hwnd, GWL.STYLE); int newStyle = style; if (needV) newStyle |= (int)WS.VSCROLL; else newStyle &= ~(int)WS.VSCROLL; if (needH) newStyle |= (int)WS.HSCROLL; else newStyle &= ~(int)WS.HSCROLL; if (newStyle != style) { - EasyWindowsApplication.Core.Win32.SetWindowLongW(Hwnd, GWL.STYLE, newStyle); - EasyWindowsApplication.Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, 0, 0, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE | SWP.NOSIZE | SWP.FRAMECHANGED); + Core.Win32.SetWindowLongW(Hwnd, GWL.STYLE, newStyle); + Core.Win32.SetWindowPos(Hwnd, 0, 0, 0, 0, 0, SWP.NOZORDER | SWP.NOACTIVATE | SWP.NOMOVE | SWP.NOSIZE | SWP.FRAMECHANGED); } _maxScrollY = Math.Max(0, totalH - _clientH); @@ -330,7 +657,7 @@ private void ConfigureScrollbars() _scrollY = Math.Clamp(_scrollY, 0, _maxScrollY); // Configurar SCROLLINFO - if (needV || _scrollConfig.VerticalScrollBarVisibility == ScrollBarVisibility.Always) + if (needV || _scrollConfig.VerticalScrollBarVisibility is ScrollBarVisibility.Always) { var si = new SCROLLINFO { @@ -341,9 +668,9 @@ private void ConfigureScrollbars() nPage = (uint)Math.Max(0, _clientH), nPos = _scrollY }; - EasyWindowsApplication.Core.Win32.SetScrollInfo(Hwnd, 1, ref si, true); + Core.Win32.SetScrollInfo(Hwnd, 1, ref si, true); } - if (needH || _scrollConfig.HorizontalScrollBarVisibility == ScrollBarVisibility.Always) + if (needH || _scrollConfig.HorizontalScrollBarVisibility is ScrollBarVisibility.Always) { var si = new SCROLLINFO { @@ -354,7 +681,7 @@ private void ConfigureScrollbars() nPage = (uint)Math.Max(0, _clientW), nPos = _scrollX }; - EasyWindowsApplication.Core.Win32.SetScrollInfo(Hwnd, 0, ref si, true); + Core.Win32.SetScrollInfo(Hwnd, 0, ref si, true); } // Sincronizar con MasterRouter para dibujado @@ -365,25 +692,25 @@ private void ConfigureScrollbars() if (_scrollX != 0 || _scrollY != 0) ApplyScrollOffset(); } - internal void RaiseResizing(int w, int h) => Resizing?.Invoke(this, new WindowResizingEventArgs(w, h)); - internal void RaiseResized(int w, int h) + internal void RaiseResizing(float w, float h) => Resizing?.Invoke(this, new WindowResizingEventArgs(w, h)); + internal void RaiseResized(float w, float h) { // Actualizar backing fields para Width/Height (evitar recursión SetWindowPos) _width = w; _height = h; - _clientW = w; - _clientH = h; + _clientW = (int)w; + _clientH = (int)h; - if (_materializedChildren != null && _contentModel != null && _materializedChildren.Count > 0) + if (_materializedChildren is not null && _contentModel is not null && _materializedChildren.Count > 0) { - var engine = new EasyWindowsApplication.Core.LayoutEngine.LayoutEngine(new VerticalStackLayoutStrategy()); + var engine = new Core.LayoutEngine.LayoutEngine(new VerticalStackLayoutStrategy()); engine.Execute(_materializedChildren, w, h, _contentModel.Spacing, _contentModel.Padding); } // Reconfigurar scrollbars tras resize (puede cambiar necesidad de scroll) - if (_scrollConfig != null) ConfigureScrollbars(); + if (_scrollConfig is not null) ConfigureScrollbars(); - Resized?.Invoke(this, new WindowResizedEventArgs(w, h)); + Resized?.Invoke(this, new WindowResizedEventArgs((int)w, (int)h)); } internal void RaiseMoved(int x, int y) => Moved?.Invoke(this, new WindowMovedEventArgs(x, y)); @@ -404,7 +731,7 @@ internal void MaterializeContent(WindowModel window, HandleRegistry registry, Ma if (availW <= 0) availW = window.Width; if (availH <= 0) availH = window.Height; - var engine = new EasyWindowsApplication.Core.LayoutEngine.LayoutEngine(new VerticalStackLayoutStrategy()); + var engine = new Core.LayoutEngine.LayoutEngine(new VerticalStackLayoutStrategy()); engine.Execute(layoutables, availW, availH, content.Spacing, content.Padding); _materializedChildren = layoutables; @@ -413,7 +740,7 @@ internal void MaterializeContent(WindowModel window, HandleRegistry registry, Ma _clientH = (int)availH; // Fase 4: Scroll - if (window.ScrollConfig != null) + if (window.ScrollConfig is not null) { _scrollConfig = window.ScrollConfig; _scrollX = (int)window.ScrollConfig.ScrollX; @@ -444,8 +771,12 @@ private static void MaterializeChildren(ContentModel model, nint parentHwnd, Han vm.Control = control; } + // Las superficies sin HWND (IMenu, IMenuItem) no se materializan aquí; + // las materializa MenuSurface (Fase 1/2). + if (control is not IControl nativeControl) continue; + // Resolver factory por tipo concreto/interfaz - if (!ControlActivatorRegistry.Shared.TryGetFactoryForControl(control, out var factory) || factory is null) + if (!ControlActivatorRegistry.Shared.TryGetFactoryForControl(nativeControl, out var factory) || factory is null) { if (vm.ControlType is not null && ControlActivatorRegistry.Shared.TryGetFactory(vm.ControlType) is { } fallback && fallback is not null) factory = fallback; @@ -453,7 +784,7 @@ private static void MaterializeChildren(ContentModel model, nint parentHwnd, Han throw new InvalidOperationException($"No handle factory registered for control '{control.GetType().Name}' (name='{control.Name}'). Registre un INativeHandleFactory para ese tipo."); } - nint hwnd = factory.CreateHandle(parentHwnd, control, registry); + nint hwnd = factory.CreateHandle(parentHwnd, nativeControl, registry); if (hwnd == 0) throw new InvalidOperationException($"CreateHandle failed for control '{control.Name}' ({control.GetType().Name})"); diff --git a/src/EasyWindowsApplication/Share/CaptionButtons.cs b/src/EasyWindowsApplication/Share/CaptionButtons.cs new file mode 100644 index 0000000..6560e3c --- /dev/null +++ b/src/EasyWindowsApplication/Share/CaptionButtons.cs @@ -0,0 +1,12 @@ +namespace EasyWindowsApplication.Share; + +/// Botones de caption nativos. +[Flags] +public enum CaptionButtons +{ + None = 0, + Close = 1, + Minimize = 2, + Maximize = 4, + All = Close | Minimize | Maximize, +} diff --git a/src/EasyWindowsApplication/Share/Dialogs/DialogResults.cs b/src/EasyWindowsApplication/Share/Dialogs/DialogResults.cs new file mode 100644 index 0000000..bcaafc2 --- /dev/null +++ b/src/EasyWindowsApplication/Share/Dialogs/DialogResults.cs @@ -0,0 +1,6 @@ +namespace EasyWindowsApplication.Share; + +public sealed record FileOpenResult(bool IsCanceled, IReadOnlyList FilePaths); +public sealed record FileSaveResult(bool IsCanceled, string? FilePath); +public sealed record FolderSelectResult(bool IsCanceled, string? FolderPath); +public sealed record TaskDialogResult(bool IsCanceled, TaskDialogChoice Choice); \ No newline at end of file diff --git a/src/EasyWindowsApplication/Share/Dialogs/FileDialogFiltersBuilder.cs b/src/EasyWindowsApplication/Share/Dialogs/FileDialogFiltersBuilder.cs new file mode 100644 index 0000000..b3aedac --- /dev/null +++ b/src/EasyWindowsApplication/Share/Dialogs/FileDialogFiltersBuilder.cs @@ -0,0 +1,13 @@ +namespace EasyWindowsApplication.Share; + +/// Builder de filtros (espejo de IContentBuilder.Children). +public interface IFileDialogFiltersBuilder +{ + IFileDialogFiltersBuilder Children(Action configure); +} + +public interface IFileFilterChildrenBuilder +{ + IFileFilterChildrenBuilder FileFilter(FilePattern preset); + IFileFilterChildrenBuilder FileFilter(string description, params FilePattern[] patterns); +} diff --git a/src/EasyWindowsApplication/Share/Dialogs/FilePattern.cs b/src/EasyWindowsApplication/Share/Dialogs/FilePattern.cs new file mode 100644 index 0000000..91dd496 --- /dev/null +++ b/src/EasyWindowsApplication/Share/Dialogs/FilePattern.cs @@ -0,0 +1,16 @@ +namespace EasyWindowsApplication.Share; + +/// +/// Patrón tipado composite: descripción + array interno de filtros. +/// El preset compuesto ES un único . +/// +public readonly record struct FilePattern(string Description, string[] Filters) +{ + internal string Win32Patterns => string.Join(';', Filters); + + /// Primera extensión del primer filtro (sin asterisco ni punto), p.ej. "png". + public string DefaultExtension => + Filters.Length > 0 && Filters[0].Length > 0 + ? Filters[0].TrimStart('*', '.').Split(';')[0] + : string.Empty; +} diff --git a/src/EasyWindowsApplication/Share/Dialogs/IOpenFileDialog.cs b/src/EasyWindowsApplication/Share/Dialogs/IOpenFileDialog.cs new file mode 100644 index 0000000..67b8f0a --- /dev/null +++ b/src/EasyWindowsApplication/Share/Dialogs/IOpenFileDialog.cs @@ -0,0 +1,16 @@ +namespace EasyWindowsApplication.Share; + +public interface IOpenFileDialog : ISystemDialog +{ + new IOpenFileDialog Name(string name); + new IOpenFileDialog Title(string title); + IOpenFileDialog Filters(Action configure); // TOP-LEVEL + IOpenFileDialog Content(Action configure); // COMPLETO + FileOpenResult Show(); +} + +public interface IOpenFileDialogContentBuilder +{ + IOpenFileDialogContentBuilder DefaultDirectory(string path); + IOpenFileDialogContentBuilder MultiSelect(bool value); +} \ No newline at end of file diff --git a/src/EasyWindowsApplication/Share/Dialogs/ISaveFileDialog.cs b/src/EasyWindowsApplication/Share/Dialogs/ISaveFileDialog.cs new file mode 100644 index 0000000..8eeef60 --- /dev/null +++ b/src/EasyWindowsApplication/Share/Dialogs/ISaveFileDialog.cs @@ -0,0 +1,17 @@ +namespace EasyWindowsApplication.Share; + +public interface ISaveFileDialog : ISystemDialog +{ + new ISaveFileDialog Name(string name); + new ISaveFileDialog Title(string title); + ISaveFileDialog Filters(Action configure); // TOP-LEVEL + ISaveFileDialog Content(Action configure); // COMPLETO + FileSaveResult Show(); +} + +public interface ISaveFileDialogContentBuilder +{ + ISaveFileDialogContentBuilder DefaultDirectory(string path); + ISaveFileDialogContentBuilder DefaultFileName(string fileName); + ISaveFileDialogContentBuilder DefaultExtension(FilePattern pattern); +} \ No newline at end of file diff --git a/src/EasyWindowsApplication/Share/Dialogs/ISelectFolderDialog.cs b/src/EasyWindowsApplication/Share/Dialogs/ISelectFolderDialog.cs new file mode 100644 index 0000000..8b661cd --- /dev/null +++ b/src/EasyWindowsApplication/Share/Dialogs/ISelectFolderDialog.cs @@ -0,0 +1,15 @@ +namespace EasyWindowsApplication.Share; + +public interface ISelectFolderDialog : ISystemDialog +{ + new ISelectFolderDialog Name(string name); + new ISelectFolderDialog Title(string title); + ISelectFolderDialog Content(Action configure); // COMPLETO + FolderSelectResult Show(); +} + +public interface ISelectFolderDialogContentBuilder +{ + ISelectFolderDialogContentBuilder DefaultDirectory(string path); + ISelectFolderDialogContentBuilder PersistLastDirectory(bool persist); // NUEVO +} \ No newline at end of file diff --git a/src/EasyWindowsApplication/Share/Dialogs/ISystemDialog.cs b/src/EasyWindowsApplication/Share/Dialogs/ISystemDialog.cs new file mode 100644 index 0000000..f3d9201 --- /dev/null +++ b/src/EasyWindowsApplication/Share/Dialogs/ISystemDialog.cs @@ -0,0 +1,8 @@ +namespace EasyWindowsApplication.Share; + +/// Marker + fluent base de los diálogos del sistema. +public interface ISystemDialog : IViewSurface +{ + new ISystemDialog Name(string name); + ISystemDialog Title(string title); +} diff --git a/src/EasyWindowsApplication/Share/Dialogs/ITaskDialog.cs b/src/EasyWindowsApplication/Share/Dialogs/ITaskDialog.cs new file mode 100644 index 0000000..f94ef46 --- /dev/null +++ b/src/EasyWindowsApplication/Share/Dialogs/ITaskDialog.cs @@ -0,0 +1,26 @@ +namespace EasyWindowsApplication.Share; + +public interface ITaskDialog : ISystemDialog +{ + // Identidad / chrome + new ITaskDialog Name(string name); + new ITaskDialog Title(string title); + + // --- MÍNIMO --- + ITaskDialog Text(string text); // cuerpo único, defaults: icono=None, Choices=Ok + + // --- COMPLETO (builder) --- + ITaskDialog Content(Action configure); + + TaskDialogResult Show(); +} + +// Builder interno del diálogo (recibido en Content) +public interface ITaskDialogContentBuilder +{ + ITaskDialogContentBuilder NotificationIcon(TaskDialogIcon icon); + ITaskDialogContentBuilder PrimaryText(string text); + ITaskDialogContentBuilder SecondaryText(string text); + ITaskDialogContentBuilder Choices(TaskDialogChoices choices); + ITaskDialogContentBuilder DefaultChoice(TaskDialogChoice choice); +} \ No newline at end of file diff --git a/src/EasyWindowsApplication/Share/Dialogs/TaskDialogEnums.cs b/src/EasyWindowsApplication/Share/Dialogs/TaskDialogEnums.cs new file mode 100644 index 0000000..826766c --- /dev/null +++ b/src/EasyWindowsApplication/Share/Dialogs/TaskDialogEnums.cs @@ -0,0 +1,33 @@ +namespace EasyWindowsApplication.Share; + +public enum TaskDialogIcon +{ + None, + Information, + Warning, + Error, + Shield +} + +[Flags] +public enum TaskDialogChoices +{ + None = 0, + Ok = 0x1, + Yes = 0x2, + No = 0x4, + Cancel = 0x8, + Retry = 0x10, + Close = 0x20 +} + +public enum TaskDialogChoice +{ + None = 0, + Ok = 1, + Yes = 6, + No = 7, + Cancel = 2, + Retry = 4, + Close = 8 +} \ No newline at end of file diff --git a/src/EasyWindowsApplication/Share/IAlternativeWindow.cs b/src/EasyWindowsApplication/Share/IAlternativeWindow.cs index d883fe8..3a76a45 100644 --- a/src/EasyWindowsApplication/Share/IAlternativeWindow.cs +++ b/src/EasyWindowsApplication/Share/IAlternativeWindow.cs @@ -1,11 +1,32 @@  namespace EasyWindowsApplication.Share; -public interface IAlternativeWindow : IBaseWindow +public interface IAlternativeWindow : IBaseWindow, IInputSurface { nint OwnerHwnd { get; } string Title { get; set; } int Width { get; set; } int Height { get; set; } WindowPositionOnScreen PositionMode { get; set; } + void Visibility(bool visible); + + /// + /// Destruye el HWND. Un Show() posterior re-crea a demanda. + /// Pasa por el veto local Closing (no termina la app). + /// + void Close(); + + // Behavior-first lifecycle (with veto on Closing) + void OnClosing(Action handler); + + /// + /// Registra un handler de "ventana cerrada". Se dispara en cada cierre del ciclo de vida + /// (Close() con app viva) pero no durante el teardown de la app. + /// Para código de apagado use . + /// + void OnClosed(Action handler); + + // LEGACY + event EventHandler? Closing; + event EventHandler? Closed; } diff --git a/src/EasyWindowsApplication/Share/IAppBehavior.cs b/src/EasyWindowsApplication/Share/IAppBehavior.cs new file mode 100644 index 0000000..f5a8232 --- /dev/null +++ b/src/EasyWindowsApplication/Share/IAppBehavior.cs @@ -0,0 +1,18 @@ +namespace EasyWindowsApplication.Share; + +public interface IAppBehavior +{ + IAppBehavior OnLaunched(Action handler); + IAppBehavior OnLaunched(Action handler); + IAppBehavior OnTerminating(Action handler); + IAppBehavior OnTerminated(Action handler); + + IAppBehavior TaskbarButtonVisibility(bool visible); + + /// + /// Única puerta de salida programática. Envía WM_CLOSE a la ventana + /// principal (misma tubería que la X): pasa por el portón + /// OnTerminating (vetable) y termina en OnTerminated. + /// + void Terminate(); +} \ No newline at end of file diff --git a/src/EasyWindowsApplication/Share/IBaseWindow.cs b/src/EasyWindowsApplication/Share/IBaseWindow.cs index 435b2b6..2166002 100644 --- a/src/EasyWindowsApplication/Share/IBaseWindow.cs +++ b/src/EasyWindowsApplication/Share/IBaseWindow.cs @@ -6,11 +6,14 @@ public interface IBaseWindow string Name { get; } void Show(); void Hide(); - void Close(); + // Behavior-first lifecycle (aligns with IAppBehavior dialect) + void OnLoaded(Action handler); + void OnActivated(Action handler); + void OnDeactivated(Action handler); + + // LEGACY: retained for compatibility (not [Obsolete] yet) event EventHandler? Loaded; - event EventHandler? Closing; - event EventHandler? Closed; event EventHandler? Activated; event EventHandler? Deactivated; } diff --git a/src/EasyWindowsApplication/Share/IBehaviorBuilder.cs b/src/EasyWindowsApplication/Share/IBehaviorBuilder.cs index f26de30..fa7fbfa 100644 --- a/src/EasyWindowsApplication/Share/IBehaviorBuilder.cs +++ b/src/EasyWindowsApplication/Share/IBehaviorBuilder.cs @@ -1,3 +1,7 @@ namespace EasyWindowsApplication.Share; -public interface IBehaviorBuilder { } +public interface IBehaviorBuilder +{ + IAppBehavior WindowsApplication { get; } + ISystemTray SystemTray { get; } +} diff --git a/src/EasyWindowsApplication/Share/IChildrenBuilder.cs b/src/EasyWindowsApplication/Share/IChildrenBuilder.cs index e2c7184..b00fc10 100644 --- a/src/EasyWindowsApplication/Share/IChildrenBuilder.cs +++ b/src/EasyWindowsApplication/Share/IChildrenBuilder.cs @@ -1,11 +1,10 @@ -using EasyWindowsApplication.Win32ControlsModule.Frontend; - -namespace EasyWindowsApplication.Share; +namespace EasyWindowsApplication.Share; public interface IChildrenBuilder { - IChildrenBuilder View(Action> configure) where T : class, IControl; - IChildrenBuilder View(Func, View> configure) where T : class, IControl; + IChildrenBuilder View() where T : class, IViewSurface; + IChildrenBuilder View(Action> configure) where T : class, IViewSurface; + IChildrenBuilder View(Func, View> configure) where T : class, IViewSurface; IChildrenBuilder View(Action configure); IChildrenBuilder Row(int row); diff --git a/src/EasyWindowsApplication/Share/IInputSurface.cs b/src/EasyWindowsApplication/Share/IInputSurface.cs new file mode 100644 index 0000000..ec320f3 --- /dev/null +++ b/src/EasyWindowsApplication/Share/IInputSurface.cs @@ -0,0 +1,32 @@ +using EasyWindowsApplication.Share.Input; + +namespace EasyWindowsApplication.Share; + +/// +/// Contrato unificado de suscripción a entradas (opt-in por tipo). +/// Solo los tipos que lo declaran exponen OnInput* al IntelliSense. +/// +public interface IInputSurface +{ + /// + /// Suscribe un handler para con (cualquier count). + /// + void OnInputWithSpatialPosition(Action handler) + where TTrigger : ISpatialPositionTrigger; + + /// + /// Suscribe un handler para con la especificación . + /// puede ser: OneTap..TenTap, o . + /// + void OnInputWithSpatialPosition(Action handler) + where TTrigger : ISpatialPositionTrigger + where TCount : ITapCountSpec; + + /// + /// Suscribe un handler para el trigger por defecto con conteo . + /// + void OnInputWithSpatialPosition(Action handler); + + void OnInputWithoutSpatialPosition(Action handler) + where TTrigger : IWithoutSpatialPositionTrigger; +} diff --git a/src/EasyWindowsApplication/Share/ILayoutBuilder.cs b/src/EasyWindowsApplication/Share/ILayoutBuilder.cs index fedb52d..a316165 100644 --- a/src/EasyWindowsApplication/Share/ILayoutBuilder.cs +++ b/src/EasyWindowsApplication/Share/ILayoutBuilder.cs @@ -5,4 +5,8 @@ public interface ILayoutBuilder { ILayoutBuilderAfterWindow Window(); ILayoutBuilderAfterWindow Window(Action configure); + ILayoutBuilderAfterWindow AlternativeWindow(); + ILayoutBuilderAfterWindow AlternativeWindow(Action configure); + ILayoutBuilderAfterWindow AlternativeWindow(Action configure) where T : class, IViewSurface; + ILayoutBuilderAfterWindow AlternativeWindow(Action configure) where TDialog : class, ISystemDialog; } diff --git a/src/EasyWindowsApplication/Share/ILayoutBuilderAfterWindow.cs b/src/EasyWindowsApplication/Share/ILayoutBuilderAfterWindow.cs index 49075bd..36d7ba5 100644 --- a/src/EasyWindowsApplication/Share/ILayoutBuilderAfterWindow.cs +++ b/src/EasyWindowsApplication/Share/ILayoutBuilderAfterWindow.cs @@ -5,4 +5,6 @@ public interface ILayoutBuilderAfterWindow { ILayoutBuilderAfterWindow AlternativeWindow(); ILayoutBuilderAfterWindow AlternativeWindow(Action configure); + ILayoutBuilderAfterWindow AlternativeWindow(Action configure) where T : class, IViewSurface; + ILayoutBuilderAfterWindow AlternativeWindow(Action configure) where TDialog : class, ISystemDialog; } diff --git a/src/EasyWindowsApplication/Share/IMenu.cs b/src/EasyWindowsApplication/Share/IMenu.cs new file mode 100644 index 0000000..e50d71c --- /dev/null +++ b/src/EasyWindowsApplication/Share/IMenu.cs @@ -0,0 +1,10 @@ +namespace EasyWindowsApplication.Share; + +public interface IMenu : IViewSurface +{ + void Show(); + void ShowAtCursor(); + void ShowAtIcon(); + void Hide(); + void Close(); +} diff --git a/src/EasyWindowsApplication/Share/IMenuItem.cs b/src/EasyWindowsApplication/Share/IMenuItem.cs new file mode 100644 index 0000000..fe11141 --- /dev/null +++ b/src/EasyWindowsApplication/Share/IMenuItem.cs @@ -0,0 +1,9 @@ +using EasyWindowsApplication.Win32ControlsModule.Frontend; + +namespace EasyWindowsApplication.Share; + +public interface IMenuItem : IViewSurface, IText, IInputSurface +{ + bool IsEnabled { get; set; } + bool IsChecked { get; set; } +} diff --git a/src/EasyWindowsApplication/Share/IMenuItemSeparator.cs b/src/EasyWindowsApplication/Share/IMenuItemSeparator.cs new file mode 100644 index 0000000..c9657f9 --- /dev/null +++ b/src/EasyWindowsApplication/Share/IMenuItemSeparator.cs @@ -0,0 +1,9 @@ +namespace EasyWindowsApplication.Share; + +public interface IMenuItemSeparator : IViewSurface +{ + // Sin miembros propios en v1 (ver proposal-imenuitemseparator.md). + // Name (heredado) sirve para orden + lookup bh.*. + // Inerte por naturaleza: el OS no permite seleccionar un separador + // (MF_SEPARATOR, sin ID ni comando), por eso no expone IInputSurface. +} diff --git a/src/EasyWindowsApplication/Share/IResourcesDictionary.cs b/src/EasyWindowsApplication/Share/IResourcesDictionary.cs index ea4db29..5791022 100644 --- a/src/EasyWindowsApplication/Share/IResourcesDictionary.cs +++ b/src/EasyWindowsApplication/Share/IResourcesDictionary.cs @@ -1,4 +1,6 @@ -namespace EasyWindowsApplication.Share; +using System.Globalization; + +namespace EasyWindowsApplication.Share; public interface IResourcesDictionary { @@ -8,6 +10,7 @@ public interface IResourcesDictionary public interface ISettingsBuilder { + ISettingsBuilder Culture(CultureInfo cultureInfo); ISettingsBuilder UseWinApi(); ISettingsBuilder AppConfigFile(Action configure); } diff --git a/src/EasyWindowsApplication/Share/ISystemTray.cs b/src/EasyWindowsApplication/Share/ISystemTray.cs new file mode 100644 index 0000000..c17f8f3 --- /dev/null +++ b/src/EasyWindowsApplication/Share/ISystemTray.cs @@ -0,0 +1,3 @@ +namespace EasyWindowsApplication.Share; + +public interface ISystemTray : IViewSurface, ISystemTrayNotifications, IInputSurface { } diff --git a/src/EasyWindowsApplication/Share/ISystemTrayNotifications.cs b/src/EasyWindowsApplication/Share/ISystemTrayNotifications.cs new file mode 100644 index 0000000..458a439 --- /dev/null +++ b/src/EasyWindowsApplication/Share/ISystemTrayNotifications.cs @@ -0,0 +1,5 @@ +namespace EasyWindowsApplication.Share; + +public interface ISystemTrayNotifications : IToolTipService, ITrayNotificationService +{ +} diff --git a/src/EasyWindowsApplication/Share/IToolTipService.cs b/src/EasyWindowsApplication/Share/IToolTipService.cs new file mode 100644 index 0000000..fb0a764 --- /dev/null +++ b/src/EasyWindowsApplication/Share/IToolTipService.cs @@ -0,0 +1,6 @@ +namespace EasyWindowsApplication.Share; + +public interface IToolTipService +{ + ISystemTray Tooltip(string text); +} diff --git a/src/EasyWindowsApplication/Share/ITrayNotificationService.cs b/src/EasyWindowsApplication/Share/ITrayNotificationService.cs new file mode 100644 index 0000000..dcbbb07 --- /dev/null +++ b/src/EasyWindowsApplication/Share/ITrayNotificationService.cs @@ -0,0 +1,7 @@ +namespace EasyWindowsApplication.Share; + +public interface ITrayNotificationService +{ + ISystemTray Notify(string title, string message); + ISystemTray DismissNotification(); +} diff --git a/src/EasyWindowsApplication/Share/IViewSurface.cs b/src/EasyWindowsApplication/Share/IViewSurface.cs new file mode 100644 index 0000000..cf81a47 --- /dev/null +++ b/src/EasyWindowsApplication/Share/IViewSurface.cs @@ -0,0 +1,28 @@ +namespace EasyWindowsApplication.Share; + +public interface IViewSurface +{ + string Name { get; set; } + LayoutLength? LayoutWidth { get; set; } + LayoutLength? LayoutHeight { get; set; } + LayoutOptions LayoutOptions { get; set; } + Thickness Margin { get; set; } + Thickness Padding { get; set; } + DockPosition Dock { get; set; } + + int GridRow { get; set; } + int GridColumn { get; set; } + int GridRowSpan { get; set; } + int GridColumnSpan { get; set; } + + Color? BackgroundColor { get; set; } + + void Visibility(bool visible) + { + if (this is IBaseWindow window) + { + if (visible) window.Show(); + else window.Hide(); + } + } +} diff --git a/src/EasyWindowsApplication/Share/IWindow.cs b/src/EasyWindowsApplication/Share/IWindow.cs index 4aa5dc0..9a9c6ae 100644 --- a/src/EasyWindowsApplication/Share/IWindow.cs +++ b/src/EasyWindowsApplication/Share/IWindow.cs @@ -1,13 +1,18 @@ - -namespace EasyWindowsApplication.Share; +namespace EasyWindowsApplication.Share; -public interface IWindow : IBaseWindow +public interface IWindow : IBaseWindow, IInputSurface { string Title { get; set; } float Width { get; set; } float Height { get; set; } WindowPositionOnScreen PositionMode { get; set; } + // Behavior-first lifecycle & geometry (#54) + void OnResizing(Action handler); + void OnResized(Action handler); + void OnMoved(Action handler); + + // LEGACY (eventos C# internos — no usar en Behavior) event EventHandler? Resizing; event EventHandler? Resized; event EventHandler? Moved; @@ -15,8 +20,28 @@ public interface IWindow : IBaseWindow void Center(); void Maximize(); void Minimize(); - void Restore(); + void RestoreState(); void Focus(); + void Visibility(bool visible); + + // Phase A: state & appearance + WindowState State { get; set; } + void EnterFullScreen(); + void ExitFullScreen(); + bool TopMost { get; set; } + double Opacity { get; set; } + WindowBackdrop Backdrop { get; set; } + ScreenCaptureMode ScreenCapture { get; set; } + + // Phase B: sizing constraints + (int MinWidth, int MinHeight) MinSize { get; set; } + (int MaxWidth, int MaxHeight) MaxSize { get; set; } + bool Resizable { get; set; } + CaptionButtons CaptionButtons { get; set; } + + // Phase D: Z-order + void BringToFront(); + void SendToBack(); (int X, int Y) ScrollOffset { get; } void ScrollTo(int x, int y); diff --git a/src/EasyWindowsApplication/Share/IWindowConfig.cs b/src/EasyWindowsApplication/Share/IWindowConfig.cs index 2e4eae0..a435069 100644 --- a/src/EasyWindowsApplication/Share/IWindowConfig.cs +++ b/src/EasyWindowsApplication/Share/IWindowConfig.cs @@ -1,16 +1,34 @@ - -namespace EasyWindowsApplication.Share; +namespace EasyWindowsApplication.Share; public interface IWindowConfig { IWindowConfig Name(string name); IWindowConfig Title(string title); IWindowConfig Dimensions(int width, int height); - IWindowConfig Position(WindowPositionOnScreen position); + IWindowConfig MinSize(int width, int height); + IWindowConfig MaxSize(int width, int height); + + /// + /// Posición unificada (coordenada, preset o snap). Sustituye a . + /// + IWindowConfig Position(IWindowPosition position); + IWindowConfig Background(Color color); + IWindowConfig Resizable(bool resizable); + IWindowConfig CaptionButtons(CaptionButtons buttons); + IWindowConfig TopMost(bool topMost); + IWindowConfig Opacity(double opacity); + IWindowConfig SystemBackdrop(WindowBackdrop backdrop); + IWindowConfig ScreenCapture(ScreenCaptureMode mode); + IWindowConfig WindowState(WindowState state); + IWindowConfig TitleBar(Action configure); IWindowConfig Scroll(Action configure); + IWindowConfig SystemTray(Action configure); IWindowContentConfig Content(Action configure) where TLayout : IStackLayout; IWindowContentConfig Content(Action configure); + + [Obsolete("Use Position(IWindowPosition) con PositionPreset(WindowPositionPreset.Center) para lo mismo. Será eliminado en la próxima major.")] + IWindowConfig Position(WindowPositionOnScreen position); } public interface IWindowContentConfig { } diff --git a/src/EasyWindowsApplication/Share/Infrastructure/ControlAccess.cs b/src/EasyWindowsApplication/Share/Infrastructure/ControlAccess.cs index 8330329..43d06c0 100644 --- a/src/EasyWindowsApplication/Share/Infrastructure/ControlAccess.cs +++ b/src/EasyWindowsApplication/Share/Infrastructure/ControlAccess.cs @@ -15,6 +15,11 @@ internal interface IBehaviorServicesController T GetWindow(string name) where T : IBaseWindow; } + internal interface ISurfaceServicesController + { + T GetSurface(string name) where T : IViewSurface; + } + internal static void SetController(IBehaviorServicesController? controller) => _controller = controller; public static T Get(string name) where T : IControl @@ -22,4 +27,7 @@ public static T Get(string name) where T : IControl public static T GetWindow(string name) where T : IBaseWindow => _controller!.GetWindow(name); + + public static T GetSurface(string name) where T : IViewSurface + => ((ISurfaceServicesController)_controller!).GetSurface(name); } \ No newline at end of file diff --git a/src/EasyWindowsApplication/Share/Input/ISpatialPositionTrigger.cs b/src/EasyWindowsApplication/Share/Input/ISpatialPositionTrigger.cs new file mode 100644 index 0000000..9e574af --- /dev/null +++ b/src/EasyWindowsApplication/Share/Input/ISpatialPositionTrigger.cs @@ -0,0 +1,15 @@ +namespace EasyWindowsApplication.Share.Input; + +public interface ISpatialPositionTrigger { } + +public readonly struct Hover : ISpatialPositionTrigger { } + +public readonly struct MainTap : ISpatialPositionTrigger { } + +public readonly struct AlternativeTap1 : ISpatialPositionTrigger { } + +public readonly struct AlternativeTap2 : ISpatialPositionTrigger { } + +public readonly struct LongTap : ISpatialPositionTrigger { } + +public readonly struct Holding : ISpatialPositionTrigger { } diff --git a/src/EasyWindowsApplication/Share/Input/ITapCountSpec.cs b/src/EasyWindowsApplication/Share/Input/ITapCountSpec.cs new file mode 100644 index 0000000..6e853d6 --- /dev/null +++ b/src/EasyWindowsApplication/Share/Input/ITapCountSpec.cs @@ -0,0 +1,8 @@ +namespace EasyWindowsApplication.Share.Input; + +/// +/// Especificación de cuantificación para triggers espaciales. +/// Unifica: conteo específico (: OneTap..TenTap), +/// sin conteo () y cualquier conteo (). +/// +public interface ITapCountSpec { } diff --git a/src/EasyWindowsApplication/Share/Input/IWithoutSpatialPositionTrigger.cs b/src/EasyWindowsApplication/Share/Input/IWithoutSpatialPositionTrigger.cs new file mode 100644 index 0000000..afcc2a0 --- /dev/null +++ b/src/EasyWindowsApplication/Share/Input/IWithoutSpatialPositionTrigger.cs @@ -0,0 +1,12 @@ +namespace EasyWindowsApplication.Share.Input; + +public interface IWithoutSpatialPositionTrigger { } + +public readonly struct KeyMenu : IWithoutSpatialPositionTrigger { } +public readonly struct KeyEsc : IWithoutSpatialPositionTrigger { } + +public readonly struct Chord : IWithoutSpatialPositionTrigger + where TMod : IKeyMod + where TKey : IKey +{ +} diff --git a/src/EasyWindowsApplication/Share/Input/KeyPrimitives.cs b/src/EasyWindowsApplication/Share/Input/KeyPrimitives.cs new file mode 100644 index 0000000..3a24936 --- /dev/null +++ b/src/EasyWindowsApplication/Share/Input/KeyPrimitives.cs @@ -0,0 +1,43 @@ +namespace EasyWindowsApplication.Share.Input; + +public interface IKey { } + +public interface IKeyMod { } + +public readonly struct KeyShift : IKeyMod { } + +public readonly struct KeyCtrl : IKeyMod { } + +public readonly struct KeyAlt : IKeyMod { } + +public readonly struct KeyWin : IKeyMod { } + +public readonly struct KeyF1 : IKey { } + +public readonly struct KeyF2 : IKey { } + +public readonly struct KeyF3 : IKey { } + +public readonly struct KeyF4 : IKey { } + +public readonly struct KeyF5 : IKey { } + +public readonly struct KeyF6 : IKey { } + +public readonly struct KeyF7 : IKey { } + +public readonly struct KeyF8 : IKey { } + +public readonly struct KeyF9 : IKey { } + +public readonly struct KeyF10 : IKey { } + +public readonly struct KeyF11 : IKey { } + +public readonly struct KeyF12 : IKey { } + +public readonly struct KeyEnter : IKey { } + +public readonly struct KeySpace : IKey { } + +public readonly struct KeyEscape : IKey { } diff --git a/src/EasyWindowsApplication/Share/Input/NonTapCount.cs b/src/EasyWindowsApplication/Share/Input/NonTapCount.cs new file mode 100644 index 0000000..b2ee4c4 --- /dev/null +++ b/src/EasyWindowsApplication/Share/Input/NonTapCount.cs @@ -0,0 +1,5 @@ +namespace EasyWindowsApplication.Share.Input; + +public readonly struct NoTap : ITapCountSpec; + +public readonly struct AnyTap : ITapCountSpec; \ No newline at end of file diff --git a/src/EasyWindowsApplication/Share/Input/TapCount.cs b/src/EasyWindowsApplication/Share/Input/TapCount.cs new file mode 100644 index 0000000..d652901 --- /dev/null +++ b/src/EasyWindowsApplication/Share/Input/TapCount.cs @@ -0,0 +1,23 @@ +namespace EasyWindowsApplication.Share.Input; + +public interface ITapCount : ITapCountSpec { } + +public readonly struct OneTap : ITapCount { } + +public readonly struct TwoTap : ITapCount { } + +public readonly struct ThreeTap : ITapCount { } + +public readonly struct FourTap : ITapCount { } + +public readonly struct FiveTap : ITapCount { } + +public readonly struct SixTap : ITapCount { } + +public readonly struct SevenTap : ITapCount { } + +public readonly struct EightTap : ITapCount { } + +public readonly struct NineTap : ITapCount { } + +public readonly struct TenTap : ITapCount { } diff --git a/src/EasyWindowsApplication/Share/ScreenCaptureMode.cs b/src/EasyWindowsApplication/Share/ScreenCaptureMode.cs new file mode 100644 index 0000000..65d0448 --- /dev/null +++ b/src/EasyWindowsApplication/Share/ScreenCaptureMode.cs @@ -0,0 +1,14 @@ +namespace EasyWindowsApplication.Share; + +/// Modo de visibilidad en capturas de pantalla (PrtSc, Teams, etc.). +public enum ScreenCaptureMode +{ + /// Contenido normal, capturable. + Allow, + + /// La ventana aparece negra en captura (WDA_EXCLUDEFROMCAPTURE, Win10 2004+). + HideContent, + + /// La ventana no aparece en captura (WDA_EXCLUDEFROMCAPTURE con fallback a WDA_MONITOR). + HideWindow, +} diff --git a/src/EasyWindowsApplication/Share/TitleBarMode.cs b/src/EasyWindowsApplication/Share/TitleBarMode.cs new file mode 100644 index 0000000..0a7eee1 --- /dev/null +++ b/src/EasyWindowsApplication/Share/TitleBarMode.cs @@ -0,0 +1,26 @@ +namespace EasyWindowsApplication.Share; + +/// Modo de title bar de la ventana principal. +public enum TitleBarMode +{ + /// Title bar nativa Win32 (WNDCLASSEX icon + SetWindowText). + Simple, + + /// Custom title bar extendida sobre el client area (DwmExtendFrameIntoClientArea). + Custom, +} + +/// Configuración de title bar. +public interface ITitleBarConfig +{ + TitleBarMode Mode { get; } + + /// Icono (HICON) en el caption. Relevante solo en . + ITitleBarConfig Icon(nint hIcon); + + /// Título mostrado en caption (si difiere de Title de la ventana). + ITitleBarConfig Title(string title); + + /// En : contenido a materializar en el área de caption. + ITitleBarConfig Custom(Action configure) where TView : class, IViewSurface; +} diff --git a/src/EasyWindowsApplication/Share/View.cs b/src/EasyWindowsApplication/Share/View.cs index 7342fe9..06d8090 100644 --- a/src/EasyWindowsApplication/Share/View.cs +++ b/src/EasyWindowsApplication/Share/View.cs @@ -14,15 +14,9 @@ internal ViewBase() { } base.OnMessage(msg, handler); return (TSelf)this; } - - public new TSelf OnClick(Action handler) - { - base.OnClick(handler); - return (TSelf)this; - } } -public sealed class View where T : class, IControl +public sealed class View where T : class, IViewSurface { private readonly List> _configure = new(); internal string? PendingName { get; private set; } @@ -152,12 +146,6 @@ public View ColumnSpan(int span) return this; } - public View OnClick(Action handler) - { - _configure.Add(c => c.OnClick(handler)); - return this; - } - public View Text(string text) { _configure.Add(c => { if (c is IText t) t.Text = text; }); @@ -175,12 +163,12 @@ internal void Apply(T instance) foreach (var a in _configure) a(instance); } - internal Action BuildConfigure() + internal Action BuildConfigure() { var snapshot = _configure.ToArray(); - return control => + return surface => { - var typed = (T)control; + var typed = (T)surface; foreach (var a in snapshot) a(typed); }; } diff --git a/src/EasyWindowsApplication/Share/WindowBackdrop.cs b/src/EasyWindowsApplication/Share/WindowBackdrop.cs new file mode 100644 index 0000000..9ad7092 --- /dev/null +++ b/src/EasyWindowsApplication/Share/WindowBackdrop.cs @@ -0,0 +1,4 @@ +namespace EasyWindowsApplication.Share; + +/// Fondo translúcido del shell (DWM). Requiere Windows 10 1809+ para efectos; en sistemas anteriores se ignora. +public enum WindowBackdrop { None, Mica, Acrylic, DesktopAcrylic, SolidColor } diff --git a/src/EasyWindowsApplication/Share/WindowPosition.cs b/src/EasyWindowsApplication/Share/WindowPosition.cs new file mode 100644 index 0000000..05e9dec --- /dev/null +++ b/src/EasyWindowsApplication/Share/WindowPosition.cs @@ -0,0 +1,40 @@ +namespace EasyWindowsApplication.Share; + +/// +/// Posición deseada de una ventana. Unifica el modelo: coordenada, preset de pantalla o snap a borde. +/// +public interface IWindowPosition { } + +/// Posición exacta en coordenadas de pantalla. +public sealed record PositionAt(int X, int Y) : IWindowPosition; + +/// Preset multi-monitor de la pantalla donde reside el handle. +public sealed record PositionPreset(WindowPositionPreset Preset) : IWindowPosition; + +/// Snap a borde de pantalla (o trackbar) estilo Aero Snap. +public sealed record PositionSnap(SnapRegion Region) : IWindowPosition; + +public enum WindowPositionPreset +{ + Center, + TopLeft, + Top, + TopRight, + Left, + Right, + BottomLeft, + Bottom, + BottomRight, +} + +public enum SnapRegion +{ + Left, + Right, + Top, + Bottom, + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} diff --git a/src/EasyWindowsApplication/Share/WindowResizingEventArgs.cs b/src/EasyWindowsApplication/Share/WindowResizingEventArgs.cs index 5a14147..d1bf54a 100644 --- a/src/EasyWindowsApplication/Share/WindowResizingEventArgs.cs +++ b/src/EasyWindowsApplication/Share/WindowResizingEventArgs.cs @@ -2,7 +2,7 @@ namespace EasyWindowsApplication.Share; public sealed class WindowResizingEventArgs : EventArgs { - public int Width { get; } - public int Height { get; } - public WindowResizingEventArgs(int width, int height) { Width = width; Height = height; } + public float Width { get; } + public float Height { get; } + public WindowResizingEventArgs(float width, float height) { Width = width; Height = height; } } diff --git a/src/EasyWindowsApplication/Share/WindowState.cs b/src/EasyWindowsApplication/Share/WindowState.cs new file mode 100644 index 0000000..b1e06a2 --- /dev/null +++ b/src/EasyWindowsApplication/Share/WindowState.cs @@ -0,0 +1,4 @@ +namespace EasyWindowsApplication.Share; + +/// Estado de la ventana principal. +public enum WindowState { Normal, Minimized, Maximized, FullScreen } diff --git a/src/EasyWindowsApplication/Win32ControlsModule/Backend/Controls.cs b/src/EasyWindowsApplication/Win32ControlsModule/Backend/Controls.cs index 3993daa..fea08a2 100644 --- a/src/EasyWindowsApplication/Win32ControlsModule/Backend/Controls.cs +++ b/src/EasyWindowsApplication/Win32ControlsModule/Backend/Controls.cs @@ -16,7 +16,6 @@ public string Text } } public bool Enabled { get; set; } = true; - public void Click() => OnClick(() => { }); public void SetStyle(uint style, bool redraw = true) { } protected override (float Width, float Height) MeasureContent(float availableWidth, float availableHeight) @@ -24,7 +23,7 @@ protected override (float Width, float Height) MeasureContent(float availableWid string text = GetWindowText(); var (tw, th) = string.IsNullOrEmpty(text) ? (20, 24) : MeasureTextByGlyphs(text); var defaults = UiDefaultsProvider.Current.GetFor(); - if (defaults == null) + if (defaults is null) defaults = UiDefaultsProvider.Current.GetFor