From e063f94165147235b6f0a0227a6e8a7fddc707ab Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sat, 25 Jul 2026 23:03:43 +0200 Subject: [PATCH 1/2] More python support --- justfile | 14 +- .../renderer/yup_AnimationFrameExporter.cpp | 6 + .../renderer/yup_AnimationFrameExporter.h | 2 + modules/yup_audio_devices/yup_audio_devices.h | 2 +- .../sources/yup_AudioFormatReaderSource.cpp | 170 ++++++++ .../sources/yup_AudioFormatReaderSource.h | 98 +++++ .../yup_audio_formats/yup_audio_formats.cpp | 1 + modules/yup_audio_formats/yup_audio_formats.h | 1 + modules/yup_gui/layout/yup_FlexBox.cpp | 373 ++++++++++++++++++ modules/yup_gui/layout/yup_FlexBox.h | 160 ++++++++ modules/yup_gui/layout/yup_FlexItem.cpp | 129 ++++++ modules/yup_gui/layout/yup_FlexItem.h | 147 +++++++ modules/yup_gui/layout/yup_Grid.cpp | 289 ++++++++++++++ modules/yup_gui/layout/yup_Grid.h | 139 +++++++ modules/yup_gui/layout/yup_GridItem.cpp | 73 ++++ modules/yup_gui/layout/yup_GridItem.h | 101 +++++ modules/yup_gui/yup_gui.cpp | 4 + modules/yup_gui/yup_gui.h | 7 + .../bindings/yup_YupAudioDevices_bindings.cpp | 224 +++++++++++ .../bindings/yup_YupAudioDevices_bindings.h | 80 ++++ .../bindings/yup_YupAudioFormats_bindings.cpp | 139 +++++++ .../bindings/yup_YupAudioFormats_bindings.h | 44 +++ .../bindings/yup_YupGraphics_bindings.cpp | 3 + .../bindings/yup_YupGui_bindings.cpp | 269 +++++++++++++ .../yup_python/bindings/yup_YupGui_bindings.h | 35 ++ .../bindings/yup_YupRhi_bindings.cpp | 368 +++++++++++++++++ .../yup_python/bindings/yup_YupRhi_bindings.h | 44 +++ .../yup_python/modules/yup_YupMain_module.cpp | 14 +- .../yup_python/yup_python_audio_devices.cpp | 22 ++ .../yup_python/yup_python_audio_formats.cpp | 22 ++ modules/yup_python/yup_python_rhi.cpp | 22 ++ python/.gitignore | 2 + python/CMakeLists.txt | 25 +- python/demos/animated_component.py | 76 ++++ python/demos/audio_device.py | 60 +++ python/demos/audio_player.py | 159 ++++++++ python/demos/audio_player_waveform.py | 211 ++++++++++ python/demos/drawables.py | 90 +++++ python/demos/emojis_component.py | 60 +++ python/demos/emojis_font_component.py | 56 +++ python/demos/gpu_canvas.py | 151 +++++++ python/demos/gpu_effects.py | 238 +++++++++++ python/demos/gpu_triangle.py | 206 ++++++++++ python/demos/hotreload_component.py | 66 ++++ python/demos/hotreload_main.py | 103 +++++ python/demos/layout_flexgrid.py | 117 ++++++ python/demos/layout_rectangles.py | 85 ++++ python/demos/matplotlib_integration.py | 134 +++++++ python/demos/numpy_audio.py | 78 ++++ python/demos/opencv_integration.py | 108 +++++ python/demos/opencv_video.py | 139 +++++++ python/demos/pil_image.py | 128 ++++++ python/demos/radio_buttons_checkboxes.py | 103 +++++ python/demos/slider_decibels.py | 67 ++++ python/demos/slider_values.py | 77 ++++ python/demos/wavetable_oscillator.py | 108 +++++ python/demos/wavetable_oscillator_numpy.py | 113 ++++++ python/demos/yup_init.py | 104 +++++ python/demos/yup_o_matic.py | 108 ++--- python/pyproject.toml | 15 +- .../tests/test_yup_audio_devices/__init__.py | 1 + .../test_AudioDeviceManager.py | 102 +++++ .../test_AudioFormatReaderSource.py | 134 +++++++ .../test_AudioSourcePlayer.py | 85 ++++ .../tests/test_yup_audio_formats/__init__.py | 1 + .../test_AudioFormatManager.py | 114 ++++++ python/tests/test_yup_graphics/test_Rhi.py | 193 +++++++++ python/tests/test_yup_gui/__init__.py | 1 + python/tests/test_yup_gui/test_Button.py | 21 + python/tests/test_yup_gui/test_FlexBox.py | 69 ++++ python/tests/test_yup_gui/test_Grid.py | 64 +++ 71 files changed, 6679 insertions(+), 95 deletions(-) create mode 100644 modules/yup_audio_formats/sources/yup_AudioFormatReaderSource.cpp create mode 100644 modules/yup_audio_formats/sources/yup_AudioFormatReaderSource.h create mode 100644 modules/yup_gui/layout/yup_FlexBox.cpp create mode 100644 modules/yup_gui/layout/yup_FlexBox.h create mode 100644 modules/yup_gui/layout/yup_FlexItem.cpp create mode 100644 modules/yup_gui/layout/yup_FlexItem.h create mode 100644 modules/yup_gui/layout/yup_Grid.cpp create mode 100644 modules/yup_gui/layout/yup_Grid.h create mode 100644 modules/yup_gui/layout/yup_GridItem.cpp create mode 100644 modules/yup_gui/layout/yup_GridItem.h create mode 100644 modules/yup_python/bindings/yup_YupAudioDevices_bindings.cpp create mode 100644 modules/yup_python/bindings/yup_YupAudioDevices_bindings.h create mode 100644 modules/yup_python/bindings/yup_YupAudioFormats_bindings.cpp create mode 100644 modules/yup_python/bindings/yup_YupAudioFormats_bindings.h create mode 100644 modules/yup_python/bindings/yup_YupRhi_bindings.cpp create mode 100644 modules/yup_python/bindings/yup_YupRhi_bindings.h create mode 100644 modules/yup_python/yup_python_audio_devices.cpp create mode 100644 modules/yup_python/yup_python_audio_formats.cpp create mode 100644 modules/yup_python/yup_python_rhi.cpp create mode 100644 python/demos/animated_component.py create mode 100644 python/demos/audio_device.py create mode 100644 python/demos/audio_player.py create mode 100644 python/demos/audio_player_waveform.py create mode 100644 python/demos/drawables.py create mode 100644 python/demos/emojis_component.py create mode 100644 python/demos/emojis_font_component.py create mode 100644 python/demos/gpu_canvas.py create mode 100644 python/demos/gpu_effects.py create mode 100644 python/demos/gpu_triangle.py create mode 100644 python/demos/hotreload_component.py create mode 100644 python/demos/hotreload_main.py create mode 100644 python/demos/layout_flexgrid.py create mode 100644 python/demos/layout_rectangles.py create mode 100644 python/demos/matplotlib_integration.py create mode 100644 python/demos/numpy_audio.py create mode 100644 python/demos/opencv_integration.py create mode 100644 python/demos/opencv_video.py create mode 100644 python/demos/pil_image.py create mode 100644 python/demos/radio_buttons_checkboxes.py create mode 100644 python/demos/slider_decibels.py create mode 100644 python/demos/slider_values.py create mode 100644 python/demos/wavetable_oscillator.py create mode 100644 python/demos/wavetable_oscillator_numpy.py create mode 100644 python/tests/test_yup_audio_devices/__init__.py create mode 100644 python/tests/test_yup_audio_devices/test_AudioDeviceManager.py create mode 100644 python/tests/test_yup_audio_devices/test_AudioFormatReaderSource.py create mode 100644 python/tests/test_yup_audio_devices/test_AudioSourcePlayer.py create mode 100644 python/tests/test_yup_audio_formats/__init__.py create mode 100644 python/tests/test_yup_audio_formats/test_AudioFormatManager.py create mode 100644 python/tests/test_yup_graphics/test_Rhi.py create mode 100644 python/tests/test_yup_gui/__init__.py create mode 100644 python/tests/test_yup_gui/test_Button.py create mode 100644 python/tests/test_yup_gui/test_FlexBox.py create mode 100644 python/tests/test_yup_gui/test_Grid.py diff --git a/justfile b/justfile index 9ada9de3b..36e8d6e84 100644 --- a/justfile +++ b/justfile @@ -89,26 +89,28 @@ emscripten_test: [doc("serve project for WASM")] emscripten_serve: - #python3 -m http.server -d . - python3 tools/serve.py -p 8000 -d . + #uv run python -m http.server -d . + uv run python tools/serve.py -p 8000 -d . [working-directory: 'python'] python_wheel: - python -m build --wheel + uv pip install build + uv run python -m build --wheel @just python_install @just python_test [working-directory: 'python'] python_install: - python -m pip install --force-reinstall dist/yup-*.whl + uv pip install --force-reinstall dist/yup-*.whl [working-directory: 'python'] python_uninstall: - python -m pip uninstall -y yup + uv pip uninstall -y yup [working-directory: 'python'] python_test *TEST_OPTS: - python -m pytest -s {{TEST_OPTS}} + uv sync --group test + uv run --group test python -m pytest -s {{TEST_OPTS}} [working-directory: 'cmake/tools/shader_bundler'] shader_bundler *COMPILE_ARGS: diff --git a/modules/yup_animation/renderer/yup_AnimationFrameExporter.cpp b/modules/yup_animation/renderer/yup_AnimationFrameExporter.cpp index 1124cae3e..0ea965df6 100644 --- a/modules/yup_animation/renderer/yup_AnimationFrameExporter.cpp +++ b/modules/yup_animation/renderer/yup_AnimationFrameExporter.cpp @@ -32,6 +32,7 @@ AnimationFrameExporter::AnimationFrameExporter (GraphicsContext& ctx) AnimationFrameExporter::~AnimationFrameExporter() = default; +//============================================================================== Size AnimationFrameExporter::resolveTargetSize (const Animation& anim, Size requested) { if (requested.getWidth() > 0 && requested.getHeight() > 0) @@ -41,6 +42,7 @@ Size AnimationFrameExporter::resolveTargetSize (const Animation& anim, Size return { (int) native.getWidth(), (int) native.getHeight() }; } +//============================================================================== Image AnimationFrameExporter::renderFrame (const Animation& anim, float frameNo, Size targetSize) @@ -63,6 +65,7 @@ Image AnimationFrameExporter::renderFrame (const Animation& anim, return img; } +//============================================================================== ResultValue> AnimationFrameExporter::renderAllFrames (const Animation& anim, Size targetSize) { if (! anim.isValid()) @@ -85,6 +88,8 @@ ResultValue> AnimationFrameExporter::renderAllFrames (const A return makeResultValueOk (std::move (frames)); } +//============================================================================== +#if YUP_IMAGE_FORMAT_GIF Result AnimationFrameExporter::exportToGif (const Animation& anim, const File& destination, Size targetSize, @@ -165,5 +170,6 @@ Result AnimationFrameExporter::exportToGif (const std::vector& frames, return Result::ok(); } +#endif } // namespace yup diff --git a/modules/yup_animation/renderer/yup_AnimationFrameExporter.h b/modules/yup_animation/renderer/yup_AnimationFrameExporter.h index 987cee00d..00386379e 100644 --- a/modules/yup_animation/renderer/yup_AnimationFrameExporter.h +++ b/modules/yup_animation/renderer/yup_AnimationFrameExporter.h @@ -88,6 +88,7 @@ class YUP_API AnimationFrameExporter [[nodiscard]] ResultValue> renderAllFrames (const Animation& anim, Size targetSize = {}); +#if YUP_IMAGE_FORMAT_GIF //============================================================================== /** Exports the animation to an animated GIF file. @@ -119,6 +120,7 @@ class YUP_API AnimationFrameExporter float frameRate, const File& destination, int qualityLevel = 80); +#endif private: static Size resolveTargetSize (const Animation& anim, Size requested); diff --git a/modules/yup_audio_devices/yup_audio_devices.h b/modules/yup_audio_devices/yup_audio_devices.h index 25dc4bde1..bc0033f69 100644 --- a/modules/yup_audio_devices/yup_audio_devices.h +++ b/modules/yup_audio_devices/yup_audio_devices.h @@ -51,7 +51,7 @@ license: ISC dependencies: yup_audio_basics yup_events - optionalDeps: yup_graphics + optionalDeps: yup_graphics yup_audio_formats appleFrameworks: CoreAudio CoreMIDI AudioToolbox iosFrameworks: AVFoundation iosSimFrameworks: AVFoundation diff --git a/modules/yup_audio_formats/sources/yup_AudioFormatReaderSource.cpp b/modules/yup_audio_formats/sources/yup_AudioFormatReaderSource.cpp new file mode 100644 index 000000000..3a6e90b34 --- /dev/null +++ b/modules/yup_audio_formats/sources/yup_AudioFormatReaderSource.cpp @@ -0,0 +1,170 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +AudioFormatReaderSource::AudioFormatReaderSource (AudioFormatReader* sourceReader, + bool deleteReaderWhenThisIsDeleted) + : reader (sourceReader) + , deleteReader (deleteReaderWhenThisIsDeleted) +{ + // Allow null reader — source produces silence +} + +AudioFormatReaderSource::AudioFormatReaderSource (std::unique_ptr sourceReader) + : ownedReader (std::move (sourceReader)) + , reader (ownedReader.get()) + , deleteReader (false) +{ +} + +AudioFormatReaderSource::~AudioFormatReaderSource() +{ + if (reader != nullptr && deleteReader) + delete reader; +} + +//============================================================================== +int64 AudioFormatReaderSource::getTotalLength() const +{ + if (reader == nullptr) + return 0; + return reader->lengthInSamples; +} + +void AudioFormatReaderSource::setNextReadPosition (int64 newPosition) +{ + if (newPosition < 0) + newPosition = 0; + + nextReadPosition = newPosition; +} + +int64 AudioFormatReaderSource::getNextReadPosition() const +{ + return nextReadPosition; +} + +bool AudioFormatReaderSource::isLooping() const +{ + return looping; +} + +void AudioFormatReaderSource::setLooping (bool shouldLoop) +{ + looping = shouldLoop; +} + +//============================================================================== +void AudioFormatReaderSource::prepareToPlay (int /*samplesPerBlockExpected*/, + double /*sampleRate*/) +{ +} + +void AudioFormatReaderSource::releaseResources() +{ +} + +void AudioFormatReaderSource::getNextAudioBlock (const AudioSourceChannelInfo& bufferToFill) +{ + if (reader == nullptr) + { + bufferToFill.clearActiveBufferRegion(); + return; + } + + const auto totalLength = getTotalLength(); + + if (totalLength > 0) + { + auto samplesAvailable = totalLength - nextReadPosition; + + if (samplesAvailable < bufferToFill.numSamples) + { + if (looping) + { + auto samplesNeeded = bufferToFill.numSamples; + auto firstChunk = static_cast (samplesAvailable); + auto secondChunk = samplesNeeded - firstChunk; + + // Read first chunk from the end of the file + if (firstChunk > 0) + { + reader->read (bufferToFill.buffer, + bufferToFill.startSample, + firstChunk, + nextReadPosition, + true, + true); + } + + // Read second chunk from the beginning of the file + if (secondChunk > 0) + { + reader->read (bufferToFill.buffer, + bufferToFill.startSample + firstChunk, + secondChunk, + 0, + true, + true); + } + + nextReadPosition = secondChunk; + } + else + { + // Read what's left and clear the rest + auto numToRead = static_cast (samplesAvailable); + + reader->read (bufferToFill.buffer, + bufferToFill.startSample, + numToRead, + nextReadPosition, + true, + true); + + bufferToFill.buffer->clear (bufferToFill.startSample + numToRead, + bufferToFill.numSamples - numToRead); + + nextReadPosition = totalLength; + } + } + else + { + reader->read (bufferToFill.buffer, + bufferToFill.startSample, + bufferToFill.numSamples, + nextReadPosition, + true, + true); + + nextReadPosition += bufferToFill.numSamples; + } + } + else + { + bufferToFill.clearActiveBufferRegion(); + } +} + +} // namespace yup diff --git a/modules/yup_audio_formats/sources/yup_AudioFormatReaderSource.h b/modules/yup_audio_formats/sources/yup_AudioFormatReaderSource.h new file mode 100644 index 000000000..3826a5dff --- /dev/null +++ b/modules/yup_audio_formats/sources/yup_AudioFormatReaderSource.h @@ -0,0 +1,98 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + A PositionableAudioSource that reads from an AudioFormatReader. + + This class wraps an AudioFormatReader, turning it into a PositionableAudioSource + that can be used with AudioTransportSource for playback of audio files. + + This is the simplest way to read from an audio file: create an AudioFormatReader + for the file, wrap it in an AudioFormatReaderSource, pass it to an + AudioTransportSource, and play. + + @see AudioFormatReader, AudioTransportSource, PositionableAudioSource + + @tags{Audio} +*/ +class YUP_API AudioFormatReaderSource : public PositionableAudioSource +{ +public: + //============================================================================== + /** Creates an AudioFormatReaderSource from an AudioFormatReader. + + @param sourceReader the reader to use as the source. The + AudioFormatReaderSource will take ownership + of this reader and delete it when no longer needed. + @param deleteReaderWhenThisIsDeleted if true, the sourceReader will be deleted + when this object is destroyed + */ + AudioFormatReaderSource (AudioFormatReader* sourceReader, + bool deleteReaderWhenThisIsDeleted); + + /** Creates an AudioFormatReaderSource from a unique_ptr. + Takes ownership of the reader. + */ + explicit AudioFormatReaderSource (std::unique_ptr sourceReader); + + /** Destructor. */ + ~AudioFormatReaderSource() override; + + //============================================================================== + /** Returns the AudioFormatReader being used as the source. */ + AudioFormatReader* getAudioFormatReader() const noexcept { return reader; } + + //============================================================================== + /** @internal */ + void prepareToPlay (int samplesPerBlockExpected, double sampleRate) override; + /** @internal */ + void releaseResources() override; + /** @internal */ + void getNextAudioBlock (const AudioSourceChannelInfo& bufferToFill) override; + + //============================================================================== + /** @internal */ + void setNextReadPosition (int64 newPosition) override; + /** @internal */ + int64 getNextReadPosition() const override; + /** @internal */ + int64 getTotalLength() const override; + /** @internal */ + bool isLooping() const override; + /** @internal */ + void setLooping (bool shouldLoop) override; + +private: + //============================================================================== + std::unique_ptr ownedReader; + AudioFormatReader* reader = nullptr; + bool deleteReader = false; + int64 nextReadPosition = 0; + bool looping = false; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioFormatReaderSource) +}; + +} // namespace yup diff --git a/modules/yup_audio_formats/yup_audio_formats.cpp b/modules/yup_audio_formats/yup_audio_formats.cpp index 4ee94a055..87bf2120a 100644 --- a/modules/yup_audio_formats/yup_audio_formats.cpp +++ b/modules/yup_audio_formats/yup_audio_formats.cpp @@ -82,6 +82,7 @@ #include "format/yup_AudioFormatReader.cpp" #include "format/yup_AudioFormatWriter.cpp" #include "common/yup_AudioFormatManager.cpp" +#include "sources/yup_AudioFormatReaderSource.cpp" //============================================================================== diff --git a/modules/yup_audio_formats/yup_audio_formats.h b/modules/yup_audio_formats/yup_audio_formats.h index 6334c0c61..a8e89982c 100644 --- a/modules/yup_audio_formats/yup_audio_formats.h +++ b/modules/yup_audio_formats/yup_audio_formats.h @@ -168,6 +168,7 @@ #include "format/yup_AudioFormatReader.h" #include "format/yup_AudioFormatWriter.h" #include "common/yup_AudioFormatManager.h" +#include "sources/yup_AudioFormatReaderSource.h" //============================================================================== diff --git a/modules/yup_gui/layout/yup_FlexBox.cpp b/modules/yup_gui/layout/yup_FlexBox.cpp new file mode 100644 index 000000000..bbca43e26 --- /dev/null +++ b/modules/yup_gui/layout/yup_FlexBox.cpp @@ -0,0 +1,373 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +FlexBox::FlexBox (Direction d) + : flexDirection (d) +{ +} + +FlexBox::FlexBox (Direction d, Wrap w, AlignItems ai, JustifyContent jc, AlignContent ac) + : flexDirection (d) + , flexWrap (w) + , alignItems (ai) + , justifyContent (jc) + , alignContent (ac) +{ +} + +//============================================================================== +static bool isRowDirection (FlexBox::Direction direction) +{ + return direction == FlexBox::Direction::row || direction == FlexBox::Direction::rowReverse; +} + +static bool isReverseDirection (FlexBox::Direction direction) +{ + return direction == FlexBox::Direction::rowReverse || direction == FlexBox::Direction::columnReverse; +} + +//============================================================================== +void FlexBox::performLayout (Rectangle targetArea) +{ + if (items.size() == 0) + return; + + // Sort items by order + Array sortedItems; + sortedItems.ensureStorageAllocated (items.size()); + + for (auto& item : items) + sortedItems.add (&item); + + std::sort (sortedItems.begin(), sortedItems.end(), [] (const FlexItem* a, const FlexItem* b) + { + return a->order < b->order; + }); + + const bool isRow = isRowDirection (flexDirection); + const bool isReverse = isReverseDirection (flexDirection); + + const float containerMainSize = isRow ? targetArea.getWidth() : targetArea.getHeight(); + const float containerCrossSize = isRow ? targetArea.getHeight() : targetArea.getWidth(); + const float containerMainStart = isRow ? targetArea.getX() : targetArea.getY(); + const float containerCrossStart = isRow ? targetArea.getY() : targetArea.getX(); + + // Build lines + Array lines; + FlexBox::LineInfo currentLine; + float currentMainSize = 0.0f; + + for (int i = 0; i < sortedItems.size(); ++i) + { + auto* item = sortedItems.getUnchecked (i); + + float itemMainSize = isRow ? item->width : item->height; + float itemMainMarginStart = isRow ? item->marginLeft : item->marginTop; + float itemMainMarginEnd = isRow ? item->marginRight : item->marginBottom; + + // If flexBasis is set, use it as the initial main size + if (item->flexBasis > 0.0f) + itemMainSize = item->flexBasis; + + const float totalItemMainSize = itemMainSize + itemMainMarginStart + itemMainMarginEnd; + + if (flexWrap != Wrap::noWrap && ! currentLine.items.isEmpty() + && currentMainSize + totalItemMainSize > containerMainSize) + { + // Start a new line + lines.add (currentLine); + currentLine = {}; + currentMainSize = 0.0f; + } + + currentLine.items.add (item); + currentMainSize += totalItemMainSize; + } + + if (! currentLine.items.isEmpty()) + lines.add (currentLine); + + // Calculate cross sizes for lines + for (auto& line : lines) + { + float maxCrossSize = 0.0f; + + for (auto* item : line.items) + { + float itemCrossSize = isRow ? item->height : item->width; + float crossMarginStart = isRow ? item->marginTop : item->marginLeft; + float crossMarginEnd = isRow ? item->marginBottom : item->marginRight; + maxCrossSize = std::max (maxCrossSize, itemCrossSize + crossMarginStart + crossMarginEnd); + } + + line.crossSize = maxCrossSize; + line.totalMainSize = 0.0f; + + for (auto* item : line.items) + { + float itemMainSize = isRow ? item->width : item->height; + if (item->flexBasis > 0.0f) + itemMainSize = item->flexBasis; + + float mainMarginStart = isRow ? item->marginLeft : item->marginTop; + float mainMarginEnd = isRow ? item->marginRight : item->marginBottom; + line.totalMainSize += itemMainSize + mainMarginStart + mainMarginEnd; + } + } + + // Calculate total cross size + float totalCrossSize = 0.0f; + for (const auto& line : lines) + totalCrossSize += line.crossSize + gap; + + if (lines.size() > 0) + totalCrossSize -= gap; + + // Align lines on cross axis + float crossOffset; + + switch (alignContent) + { + case AlignContent::flexStart: + crossOffset = 0.0f; + break; + case AlignContent::flexEnd: + crossOffset = containerCrossSize - totalCrossSize; + break; + case AlignContent::center: + crossOffset = (containerCrossSize - totalCrossSize) / 2.0f; + break; + case AlignContent::spaceBetween: + crossOffset = 0.0f; + break; + case AlignContent::spaceAround: + crossOffset = (containerCrossSize - totalCrossSize) / (float) (lines.size() + 1); + break; + case AlignContent::stretch: + crossOffset = 0.0f; + break; + } + + // Position items + float currentCrossPos = containerCrossStart + crossOffset; + + for (int lineIdx = 0; lineIdx < lines.size(); ++lineIdx) + { + auto& line = lines.getReference (lineIdx); + + // Calculate cross size for this line + float lineCrossSize = line.crossSize; + + if (alignContent == AlignContent::stretch && lines.size() > 1) + lineCrossSize = (containerCrossSize - totalCrossSize) / (float) lines.size() + line.crossSize; + + if (alignContent == AlignContent::spaceBetween && lines.size() > 1) + { + if (lineIdx == 0) + currentCrossPos = containerCrossStart; + else if (lineIdx == lines.size() - 1) + currentCrossPos = containerCrossStart + containerCrossSize - lineCrossSize; + else + currentCrossPos = containerCrossStart + (containerCrossSize - totalCrossSize) * (float) lineIdx / (float) (lines.size() - 1); + } + + // Calculate flex-grow + float totalFlexGrow = 0.0f; + float totalFixedSize = 0.0f; + + for (auto* item : line.items) + { + float itemMainSize = isRow ? item->width : item->height; + float mainMarginStart = isRow ? item->marginLeft : item->marginTop; + float mainMarginEnd = isRow ? item->marginRight : item->marginBottom; + + if (item->flexBasis > 0.0f) + itemMainSize = item->flexBasis; + + totalFixedSize += itemMainSize + mainMarginStart + mainMarginEnd; + + if (item->flexGrow > 0.0f) + totalFlexGrow += item->flexGrow; + } + + float extraSpace = containerMainSize - totalFixedSize; + + // Calculate main axis offset + float mainOffset; + + switch (justifyContent) + { + case JustifyContent::flexStart: + mainOffset = 0.0f; + break; + case JustifyContent::flexEnd: + mainOffset = extraSpace; + break; + case JustifyContent::center: + mainOffset = extraSpace / 2.0f; + break; + case JustifyContent::spaceBetween: + mainOffset = 0.0f; + break; + case JustifyContent::spaceAround: + mainOffset = extraSpace / (float) (line.items.size() + 1); + break; + } + + float currentMainPos = containerMainStart + mainOffset; + int gapCount = 0; + + for (auto* item : line.items) + { + float itemMainSize = isRow ? item->width : item->height; + float itemCrossSize = isRow ? item->height : item->width; + float mainMarginStart = isRow ? item->marginLeft : item->marginTop; + float mainMarginEnd = isRow ? item->marginRight : item->marginBottom; + float crossMarginStart = isRow ? item->marginTop : item->marginLeft; + float crossMarginEnd = isRow ? item->marginBottom : item->marginRight; + + if (item->flexBasis > 0.0f) + itemMainSize = item->flexBasis; + + // Apply flex-grow + if (totalFlexGrow > 0 && item->flexGrow > 0) + itemMainSize += extraSpace * item->flexGrow / totalFlexGrow; + + // Apply min/max constraints + float constraintMinMainSize = isRow ? item->minWidth : item->minHeight; + float constraintMaxMainSize = isRow ? item->maxWidth : item->maxHeight; + + if (constraintMinMainSize >= 0) + itemMainSize = std::max (itemMainSize, constraintMinMainSize); + if (constraintMaxMainSize >= 0) + itemMainSize = std::min (itemMainSize, constraintMaxMainSize); + + // Align on cross axis + FlexItem::AlignSelf align = item->alignSelf; + if (align == FlexItem::AlignSelf::autoAlign) + { + switch (alignItems) + { + case AlignItems::flexStart: + align = FlexItem::AlignSelf::flexStart; + break; + case AlignItems::flexEnd: + align = FlexItem::AlignSelf::flexEnd; + break; + case AlignItems::center: + align = FlexItem::AlignSelf::center; + break; + case AlignItems::stretch: + align = FlexItem::AlignSelf::stretch; + break; + } + } + + float itemCrossPos; + + if (align == FlexItem::AlignSelf::stretch) + { + itemCrossSize = lineCrossSize - crossMarginStart - crossMarginEnd; + + float constraintMinCrossSize = isRow ? item->minHeight : item->minWidth; + float constraintMaxCrossSize = isRow ? item->maxHeight : item->maxWidth; + + if (constraintMinCrossSize >= 0) + itemCrossSize = std::max (itemCrossSize, constraintMinCrossSize); + if (constraintMaxCrossSize >= 0) + itemCrossSize = std::min (itemCrossSize, constraintMaxCrossSize); + + itemCrossPos = currentCrossPos + crossMarginStart; + } + else + { + switch (align) + { + case FlexItem::AlignSelf::flexStart: + itemCrossPos = currentCrossPos + crossMarginStart; + break; + case FlexItem::AlignSelf::flexEnd: + itemCrossPos = currentCrossPos + lineCrossSize - itemCrossSize - crossMarginEnd; + break; + case FlexItem::AlignSelf::center: + default: + itemCrossPos = currentCrossPos + (lineCrossSize - itemCrossSize) / 2.0f; + break; + } + } + + // Apply spacing + if (justifyContent == JustifyContent::spaceBetween && line.items.size() > 1 && gapCount > 0) + currentMainPos += extraSpace / (float) (line.items.size() - 1); + else if (justifyContent == JustifyContent::spaceAround && gapCount > 0) + currentMainPos += mainOffset; + + currentMainPos += mainMarginStart; + + // Set bounds + if (item->associatedComponent != nullptr) + { + float x, y, w, h; + + if (isRow) + { + x = currentMainPos; + y = itemCrossPos; + w = itemMainSize; + h = itemCrossSize; + } + else + { + x = itemCrossPos; + y = currentMainPos; + w = itemCrossSize; + h = itemMainSize; + } + + if (isReverse) + { + if (isRow) + x = targetArea.getRight() - (x - targetArea.getX()) - w; + else + y = targetArea.getBottom() - (y - targetArea.getY()) - h; + } + + item->associatedComponent->setBounds (Rectangle (x, y, w, h).toNearestInt()); + } + + currentMainPos += itemMainSize + mainMarginEnd + gap; + ++gapCount; + } + + currentCrossPos += lineCrossSize + gap; + } +} + +void FlexBox::performLayout (Rectangle targetArea) +{ + performLayout (targetArea.to()); +} + +} // namespace yup diff --git a/modules/yup_gui/layout/yup_FlexBox.h b/modules/yup_gui/layout/yup_FlexBox.h new file mode 100644 index 000000000..ecee7eac2 --- /dev/null +++ b/modules/yup_gui/layout/yup_FlexBox.h @@ -0,0 +1,160 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + A CSS-flexbox-style layout container for arranging components. + + FlexBox provides a flexible layout system for positioning child components + within a given area. It follows the CSS Flexbox layout algorithm, supporting + row/column direction, wrapping, alignment, and flexible sizing via FlexItem. + + Components can be added directly (implicitly converted to FlexItem), or as + FlexItem objects with specific layout properties. + + Usage: + @code + FlexBox fb; + fb.flexDirection = FlexBox::Direction::row; + fb.justifyContent = FlexBox::JustifyContent::spaceBetween; + fb.items.add (component1.withFlex (1)); + fb.items.add (component2.withFlex (2)); + fb.performLayout (getLocalBounds()); + @endcode + + @see FlexItem + + @tags{GUI} +*/ +class YUP_API FlexBox +{ +public: + //============================================================================== + /** Direction of the flex layout. */ + enum class Direction + { + row, /**< Left to right */ + rowReverse, /**< Right to left */ + column, /**< Top to bottom */ + columnReverse /**< Bottom to top */ + }; + + /** Wrapping behavior for items that overflow. */ + enum class Wrap + { + noWrap, /**< All items on one line */ + wrap, /**< Wrap to next line */ + wrapReverse /**< Wrap to next line in reverse */ + }; + + /** Alignment of items along the main axis. */ + enum class JustifyContent + { + flexStart, /**< Pack at start */ + flexEnd, /**< Pack at end */ + center, /**< Pack centered */ + spaceBetween, /**< Even spacing between items */ + spaceAround /**< Even spacing around items */ + }; + + /** Alignment of items along the cross axis. */ + enum class AlignItems + { + flexStart, /**< Align to start */ + flexEnd, /**< Align to end */ + center, /**< Center */ + stretch /**< Stretch to fill */ + }; + + /** Alignment of lines when there is extra space on the cross axis. */ + enum class AlignContent + { + flexStart, /**< Pack at start */ + flexEnd, /**< Pack at end */ + center, /**< Pack centered */ + spaceBetween, /**< Even spacing between lines */ + spaceAround, /**< Even spacing around lines */ + stretch /**< Stretch lines to fill */ + }; + + //============================================================================== + FlexBox() = default; + + /** Creates a FlexBox with a direction. */ + explicit FlexBox (Direction direction); + + /** Creates a FlexBox with a direction, wrap and alignment settings. */ + FlexBox (Direction direction, Wrap wrap, AlignItems alignItems, JustifyContent justifyContent, AlignContent alignContent); + + //============================================================================== + /** The flex direction. Default is row. */ + Direction flexDirection = Direction::row; + + /** The wrap mode. Default is no wrap. */ + Wrap flexWrap = Wrap::noWrap; + + /** How items are aligned on the cross axis. Default is stretch. */ + AlignItems alignItems = AlignItems::stretch; + + /** How items are justified on the main axis. Default is flex-start. */ + JustifyContent justifyContent = JustifyContent::flexStart; + + /** How wrapped lines are aligned on the cross axis. Default is stretch. */ + AlignContent alignContent = AlignContent::stretch; + + /** The gap between items on the main axis. */ + float gap = 0.0f; + + //============================================================================== + /** The items to be laid out. */ + Array items; + + //============================================================================== + /** + Performs the flexbox layout, positioning child components within the + given rectangle. + + @param targetArea the area in which to lay out the items. + */ + void performLayout (Rectangle targetArea); + + /** + Performs the flexbox layout using an integer rectangle. + @param targetArea the area in which to lay out the items. + */ + void performLayout (Rectangle targetArea); + +private: + //============================================================================== + struct LineInfo + { + Array items; + float totalMainSize; + float crossSize; + }; + + void calculateLayout (Rectangle targetArea, Array& lines); +}; + +} // namespace yup diff --git a/modules/yup_gui/layout/yup_FlexItem.cpp b/modules/yup_gui/layout/yup_FlexItem.cpp new file mode 100644 index 000000000..7cd5a2a87 --- /dev/null +++ b/modules/yup_gui/layout/yup_FlexItem.cpp @@ -0,0 +1,129 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +FlexItem::FlexItem (Component& c) + : associatedComponent (&c) +{ +} + +FlexItem::FlexItem (Component* c) + : associatedComponent (c) +{ +} + +FlexItem::FlexItem (float w, float h) + : width (w) + , height (h) +{ +} + +FlexItem::FlexItem (Component& c, float w, float h) + : associatedComponent (&c) + , width (w) + , height (h) +{ +} + +FlexItem::FlexItem (Component* c, float w, float h) + : associatedComponent (c) + , width (w) + , height (h) +{ +} + +FlexItem FlexItem::withFlex (float newFlexGrow) const +{ + auto copy = *this; + copy.flexGrow = newFlexGrow; + return copy; +} + +FlexItem FlexItem::withWidth (float newWidth) const +{ + auto copy = *this; + copy.width = newWidth; + return copy; +} + +FlexItem FlexItem::withHeight (float newHeight) const +{ + auto copy = *this; + copy.height = newHeight; + return copy; +} + +FlexItem FlexItem::withMinWidth (float newMinWidth) const +{ + auto copy = *this; + copy.minWidth = newMinWidth; + return copy; +} + +FlexItem FlexItem::withMinHeight (float newMinHeight) const +{ + auto copy = *this; + copy.minHeight = newMinHeight; + return copy; +} + +FlexItem FlexItem::withMaxWidth (float newMaxWidth) const +{ + auto copy = *this; + copy.maxWidth = newMaxWidth; + return copy; +} + +FlexItem FlexItem::withMaxHeight (float newMaxHeight) const +{ + auto copy = *this; + copy.maxHeight = newMaxHeight; + return copy; +} + +FlexItem FlexItem::withMargin (float newMargin) const +{ + auto copy = *this; + copy.marginLeft = newMargin; + copy.marginRight = newMargin; + copy.marginTop = newMargin; + copy.marginBottom = newMargin; + return copy; +} + +FlexItem FlexItem::withAlignSelf (AlignSelf newAlignSelf) const +{ + auto copy = *this; + copy.alignSelf = newAlignSelf; + return copy; +} + +FlexItem FlexItem::withOrder (int newOrder) const +{ + auto copy = *this; + copy.order = newOrder; + return copy; +} + +} // namespace yup diff --git a/modules/yup_gui/layout/yup_FlexItem.h b/modules/yup_gui/layout/yup_FlexItem.h new file mode 100644 index 000000000..5e90b00cd --- /dev/null +++ b/modules/yup_gui/layout/yup_FlexItem.h @@ -0,0 +1,147 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + Describes the layout properties of a single item inside a FlexBox container. + + Each FlexItem wraps a Component and specifies how it should be sized and + positioned within the flex layout. Properties include flex-grow, flex-shrink, + min/max sizes, alignment, margins, and order. + + A Component* can be implicitly converted to a FlexItem, allowing components + to be added to a FlexBox directly. + + @see FlexBox + + @tags{GUI} +*/ +class YUP_API FlexItem +{ +public: + //============================================================================== + /** Creates a FlexItem with no associated component. */ + FlexItem() = default; + + /** Creates a FlexItem that controls the layout of the given component. */ + FlexItem (Component& component); + + /** Creates a FlexItem that controls the layout of the given component. */ + FlexItem (Component* component); + + /** Creates a FlexItem with a fixed width and height. */ + FlexItem (float width, float height); + + /** Creates a FlexItem with a fixed width and height, and the given component. */ + FlexItem (Component& component, float width, float height); + + /** Creates a FlexItem with a fixed width and height, and the given component. */ + FlexItem (Component* component, float width, float height); + + //============================================================================== + /** The component associated with this flex item, or nullptr. */ + Component* associatedComponent = nullptr; + + /** The flex-grow factor. Controls how this item grows relative to others. */ + float flexGrow = 0.0f; + + /** The flex-shrink factor. Controls how this item shrinks relative to others. */ + float flexShrink = 1.0f; + + /** The flex-basis value: the initial main size before growing/shrinking. */ + float flexBasis = 0.0f; + + //============================================================================== + /** Minimum width constraint. -1 means no constraint. */ + float minWidth = -1.0f; + + /** Minimum height constraint. -1 means no constraint. */ + float minHeight = -1.0f; + + /** Maximum width constraint. -1 means no constraint. */ + float maxWidth = -1.0f; + + /** Maximum height constraint. -1 means no constraint. */ + float maxHeight = -1.0f; + + //============================================================================== + /** The width of the item. */ + float width = 0.0f; + + /** The height of the item. */ + float height = 0.0f; + + //============================================================================== + /** Enumeration of possible alignment values for the cross-axis. */ + enum class AlignSelf + { + autoAlign, /**< Use the container's align-items value */ + flexStart, /**< Align to the start of the cross axis */ + flexEnd, /**< Align to the end of the cross axis */ + center, /**< Center along the cross axis */ + stretch /**< Stretch to fill the cross axis */ + }; + + /** The alignment of this item on the cross axis. */ + AlignSelf alignSelf = AlignSelf::autoAlign; + + //============================================================================== + /** Margin values for the item (in pixels). */ + float marginLeft = 0.0f; + float marginRight = 0.0f; + float marginTop = 0.0f; + float marginBottom = 0.0f; + + /** Order value. Items with lower order are laid out first. */ + int order = 0; + + //============================================================================== + /** Returns a copy of this FlexItem with a different flex-grow. */ + FlexItem withFlex (float newFlexGrow) const; + + /** Returns a copy of this FlexItem with a different width. */ + FlexItem withWidth (float newWidth) const; + + /** Returns a copy of this FlexItem with a different height. */ + FlexItem withHeight (float newHeight) const; + + /** Returns a copy of this FlexItem with different minimum dimensions. */ + FlexItem withMinWidth (float newMinWidth) const; + FlexItem withMinHeight (float newMinHeight) const; + + /** Returns a copy of this FlexItem with different maximum dimensions. */ + FlexItem withMaxWidth (float newMaxWidth) const; + FlexItem withMaxHeight (float newMaxHeight) const; + + /** Returns a copy of this FlexItem with different margins. */ + FlexItem withMargin (float newMargin) const; + + /** Returns a copy of this FlexItem with a different align-self value. */ + FlexItem withAlignSelf (AlignSelf newAlignSelf) const; + + /** Returns a copy of this FlexItem with a different order. */ + FlexItem withOrder (int newOrder) const; +}; + +} // namespace yup diff --git a/modules/yup_gui/layout/yup_Grid.cpp b/modules/yup_gui/layout/yup_Grid.cpp new file mode 100644 index 000000000..ed30df658 --- /dev/null +++ b/modules/yup_gui/layout/yup_Grid.cpp @@ -0,0 +1,289 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +Grid::TrackInfo Grid::TrackInfo::px (float pixelSize) +{ + TrackInfo t; + t.pixelSize = pixelSize; + return t; +} + +Grid::TrackInfo Grid::TrackInfo::fr (float fraction) +{ + TrackInfo t; + t.fraction = fraction; + return t; +} + +Grid::TrackInfo Grid::TrackInfo::auto_() +{ + TrackInfo t; + t.isAuto = true; + return t; +} + +//============================================================================== +Array Grid::calculateTrackSizes (const Array& tracks, + float totalSize, + float defaultSize) +{ + Array sizes; + float usedSize = 0.0f; + float totalFr = 0.0f; + + if (tracks.isEmpty()) + { + // Auto-track mode: one column per item + return sizes; // will be handled by the caller + } + + sizes.resize (tracks.size()); + + // First pass: allocate fixed and count fr units + for (int i = 0; i < tracks.size(); ++i) + { + const auto& track = tracks.getReference (i); + + if (track.isAuto) + { + sizes.set (i, defaultSize); + usedSize += defaultSize; + } + else if (track.fraction > 0.0f) + { + totalFr += track.fraction; + sizes.set (i, 0.0f); + } + else + { + sizes.set (i, track.pixelSize); + usedSize += track.pixelSize; + } + } + + // Distribute remaining space by fr units + if (totalFr > 0.0f) + { + float remaining = std::max (0.0f, totalSize - usedSize); + + for (int i = 0; i < tracks.size(); ++i) + { + const auto& track = tracks.getReference (i); + + if (track.fraction > 0.0f) + { + sizes.set (i, remaining * track.fraction / totalFr); + } + } + } + + return sizes; +} + +//============================================================================== +void Grid::performLayout (Rectangle targetArea) +{ + if (items.isEmpty()) + return; + + // Calculate column widths + Array columnWidths; + float totalColWidth = targetArea.getWidth(); + + if (! templateColumns.isEmpty()) + columnWidths = calculateTrackSizes (templateColumns, totalColWidth, autoColumns); + else + { + // Auto-layout: place items in a single row with fixed width columns + columnWidths.add (autoColumns); + } + + // Calculate row heights + Array rowHeights; + float totalRowHeight = targetArea.getHeight(); + + if (! templateRows.isEmpty()) + rowHeights = calculateTrackSizes (templateRows, totalRowHeight, autoRows); + else + { + // Auto-layout: one row per item + rowHeights.add (autoRows); + } + + // Calculate cell positions + Array columnPositions; + float currentX = targetArea.getX(); + + for (auto width : columnWidths) + { + columnPositions.add (currentX); + currentX += width + columnGap; + } + + Array rowPositions; + float currentY = targetArea.getY(); + + for (auto height : rowHeights) + { + rowPositions.add (currentY); + currentY += height + rowGap; + } + + // Position each item + for (const auto& item : items) + { + if (item.associatedComponent == nullptr) + continue; + + // Calculate cell bounds + const int col = item.column; + const int row = item.row; + const int colSpan = item.columnSpan; + const int rowSpan = item.rowSpan; + + if (col < 0 || row < 0) + continue; + + float cellX = 0.0f; + float cellY = 0.0f; + float cellW = 100.0f; + float cellH = 100.0f; + + if (! columnPositions.isEmpty() && col < columnPositions.size()) + { + cellX = columnPositions[col]; + + if (col + colSpan <= columnPositions.size()) + { + float endX = columnPositions.getUnchecked (col + colSpan - 1) + columnWidths[col + colSpan - 1]; + cellW = endX - cellX; + } + else + { + cellW = columnWidths[col]; + } + } + + if (! rowPositions.isEmpty() && row < rowPositions.size()) + { + cellY = rowPositions[row]; + + if (row + rowSpan <= rowPositions.size()) + { + float endY = rowPositions.getUnchecked (row + rowSpan - 1) + rowHeights[row + rowSpan - 1]; + cellH = endY - cellY; + } + else + { + cellH = rowHeights[row]; + } + } + + // Apply margins + cellX += item.marginLeft; + cellY += item.marginTop; + cellW -= item.marginLeft + item.marginRight; + cellH -= item.marginTop + item.marginBottom; + + // Apply alignment + GridItem::AlignSelf hAlign = item.justifySelf; + GridItem::AlignSelf vAlign = item.alignSelf; + + if (hAlign == GridItem::AlignSelf::autoAlign) + { + switch (justifyItems) + { + case AlignItems::flexStart: + hAlign = GridItem::AlignSelf::flexStart; + break; + case AlignItems::flexEnd: + hAlign = GridItem::AlignSelf::flexEnd; + break; + case AlignItems::center: + hAlign = GridItem::AlignSelf::center; + break; + case AlignItems::stretch: + hAlign = GridItem::AlignSelf::stretch; + break; + } + } + + if (vAlign == GridItem::AlignSelf::autoAlign) + { + switch (alignItems) + { + case AlignItems::flexStart: + vAlign = GridItem::AlignSelf::flexStart; + break; + case AlignItems::flexEnd: + vAlign = GridItem::AlignSelf::flexEnd; + break; + case AlignItems::center: + vAlign = GridItem::AlignSelf::center; + break; + case AlignItems::stretch: + vAlign = GridItem::AlignSelf::stretch; + break; + } + } + + float itemX = cellX; + float itemY = cellY; + float itemW = 100.0f; + float itemH = 100.0f; + + // Component's preferred size + if (hAlign == GridItem::AlignSelf::stretch) + itemW = cellW; + else + itemW = 100.0f; // Default width + + if (vAlign == GridItem::AlignSelf::stretch) + itemH = cellH; + else + itemH = 100.0f; // Default height + + // Apply horizontal alignment + if (hAlign == GridItem::AlignSelf::center) + itemX = cellX + (cellW - itemW) / 2.0f; + else if (hAlign == GridItem::AlignSelf::flexEnd) + itemX = cellX + cellW - itemW; + + // Apply vertical alignment + if (vAlign == GridItem::AlignSelf::center) + itemY = cellY + (cellH - itemH) / 2.0f; + else if (vAlign == GridItem::AlignSelf::flexEnd) + itemY = cellY + cellH - itemH; + + item.associatedComponent->setBounds (Rectangle (itemX, itemY, itemW, itemH).toNearestInt()); + } +} + +void Grid::performLayout (Rectangle targetArea) +{ + performLayout (targetArea.to()); +} + +} // namespace yup diff --git a/modules/yup_gui/layout/yup_Grid.h b/modules/yup_gui/layout/yup_Grid.h new file mode 100644 index 000000000..425ec43c4 --- /dev/null +++ b/modules/yup_gui/layout/yup_Grid.h @@ -0,0 +1,139 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + A CSS-grid-style layout container for arranging components. + + Grid provides a two-dimensional layout system based on rows and columns. + Items are placed using explicit row/column positions with spans, similar + to CSS Grid Layout. + + Usage: + @code + Grid grid; + grid.templateColumns.add (Grid::TrackInfo (Grid::Fr (1))); + grid.templateColumns.add (Grid::TrackInfo (Grid::Fr (2))); + grid.templateRows.add (Grid::TrackInfo (50)); + grid.templateRows.add (Grid::TrackInfo (Grid::Fr (1))); + grid.items.add (component1.withColumn (0).withRow (0)); + grid.items.add (component2.withColumn (1).withRow (0).withRowSpan (2)); + grid.performLayout (getLocalBounds()); + @endcode + + @see GridItem + + @tags{GUI} +*/ +class YUP_API Grid +{ +public: + //============================================================================== + /** Represents a grid track (row or column) sizing specification. */ + struct YUP_API TrackInfo + { + /** Creates a track with a fixed pixel size. */ + static TrackInfo px (float pixelSize); + + /** Creates a track with a fractional size (fr unit). */ + static TrackInfo fr (float fraction); + + /** Creates a track with auto sizing. */ + static TrackInfo auto_(); + + /** The pixel size for fixed tracks. */ + float pixelSize = 0.0f; + + /** The fractional size (fr units). */ + float fraction = 0.0f; + + /** Whether this track uses auto-sizing. */ + bool isAuto = false; + + private: + TrackInfo() = default; + }; + + //============================================================================== + /** Alignment of items within cells. */ + enum class AlignItems + { + flexStart, + flexEnd, + center, + stretch + }; + + //============================================================================== + Grid() = default; + + //============================================================================== + /** Column track definitions. */ + Array templateColumns; + + /** Row track definitions. */ + Array templateRows; + + /** Auto-generated row height (when templateRows is empty). */ + float autoRows = 40.0f; + + /** Auto-generated column width (when templateColumns is empty). */ + float autoColumns = 100.0f; + + /** Gap between columns. */ + float columnGap = 0.0f; + + /** Gap between rows. */ + float rowGap = 0.0f; + + /** Default horizontal alignment for items. */ + AlignItems justifyItems = AlignItems::stretch; + + /** Default vertical alignment for items. */ + AlignItems alignItems = AlignItems::stretch; + + //============================================================================== + /** The items to be laid out. */ + Array items; + + //============================================================================== + /** + Performs the grid layout, positioning child components within the + given rectangle. + + @param targetArea the area in which to lay out the items. + */ + void performLayout (Rectangle targetArea); + + /** + Performs the grid layout using an integer rectangle. + */ + void performLayout (Rectangle targetArea); + +private: + //============================================================================== + Array calculateTrackSizes (const Array& tracks, float totalSize, float defaultSize); +}; + +} // namespace yup diff --git a/modules/yup_gui/layout/yup_GridItem.cpp b/modules/yup_gui/layout/yup_GridItem.cpp new file mode 100644 index 000000000..3625b447d --- /dev/null +++ b/modules/yup_gui/layout/yup_GridItem.cpp @@ -0,0 +1,73 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +GridItem::GridItem (Component& c) + : associatedComponent (&c) +{ +} + +GridItem::GridItem (Component* c) + : associatedComponent (c) +{ +} + +GridItem GridItem::withColumn (int newColumn) const +{ + auto copy = *this; + copy.column = newColumn; + return copy; +} + +GridItem GridItem::withRow (int newRow) const +{ + auto copy = *this; + copy.row = newRow; + return copy; +} + +GridItem GridItem::withColumnSpan (int newSpan) const +{ + auto copy = *this; + copy.columnSpan = newSpan; + return copy; +} + +GridItem GridItem::withRowSpan (int newSpan) const +{ + auto copy = *this; + copy.rowSpan = newSpan; + return copy; +} + +GridItem GridItem::withMargin (float newMargin) const +{ + auto copy = *this; + copy.marginLeft = newMargin; + copy.marginRight = newMargin; + copy.marginTop = newMargin; + copy.marginBottom = newMargin; + return copy; +} + +} // namespace yup diff --git a/modules/yup_gui/layout/yup_GridItem.h b/modules/yup_gui/layout/yup_GridItem.h new file mode 100644 index 000000000..d336f9ffd --- /dev/null +++ b/modules/yup_gui/layout/yup_GridItem.h @@ -0,0 +1,101 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + Describes the layout properties of a single item inside a Grid container. + + Each GridItem wraps a Component and specifies its placement within the grid + using row/column positions, spans, and alignment properties. + + A Component* can be implicitly converted to a GridItem. + + @see Grid + + @tags{GUI} +*/ +class YUP_API GridItem +{ +public: + //============================================================================== + /** Creates a GridItem with no associated component. */ + GridItem() = default; + + /** Creates a GridItem that controls the layout of the given component. */ + GridItem (Component& component); + + /** Creates a GridItem that controls the layout of the given component. */ + GridItem (Component* component); + + //============================================================================== + /** The component associated with this grid item, or nullptr. */ + Component* associatedComponent = nullptr; + + //============================================================================== + /** The column position (0-based). */ + int column = 0; + + /** The row position (0-based). */ + int row = 0; + + /** Number of columns this item spans. */ + int columnSpan = 1; + + /** Number of rows this item spans. */ + int rowSpan = 1; + + //============================================================================== + /** Enumeration of alignment values. */ + enum class AlignSelf + { + autoAlign, /**< Use the container's default */ + flexStart, /**< Align to start */ + flexEnd, /**< Align to end */ + center, /**< Center */ + stretch /**< Stretch to fill */ + }; + + /** Horizontal alignment within the cell. */ + AlignSelf justifySelf = AlignSelf::autoAlign; + + /** Vertical alignment within the cell. */ + AlignSelf alignSelf = AlignSelf::autoAlign; + + //============================================================================== + /** Margin values for the item (in pixels). */ + float marginLeft = 0.0f; + float marginRight = 0.0f; + float marginTop = 0.0f; + float marginBottom = 0.0f; + + //============================================================================== + /** Returns a copy of this GridItem with the given column/row. */ + GridItem withColumn (int newColumn) const; + GridItem withRow (int newRow) const; + GridItem withColumnSpan (int newSpan) const; + GridItem withRowSpan (int newSpan) const; + GridItem withMargin (float newMargin) const; +}; + +} // namespace yup diff --git a/modules/yup_gui/yup_gui.cpp b/modules/yup_gui/yup_gui.cpp index da1261bc2..ac67dde5a 100644 --- a/modules/yup_gui/yup_gui.cpp +++ b/modules/yup_gui/yup_gui.cpp @@ -160,6 +160,10 @@ #include "artboard/yup_Artboard.cpp" #include "windowing/yup_DocumentWindow.cpp" #include "dialogs/yup_FileChooser.cpp" +#include "layout/yup_FlexItem.cpp" +#include "layout/yup_FlexBox.cpp" +#include "layout/yup_GridItem.cpp" +#include "layout/yup_Grid.cpp" #include "themes/yup_ApplicationTheme.cpp" //============================================================================== diff --git a/modules/yup_gui/yup_gui.h b/modules/yup_gui/yup_gui.h index 0db93dcdd..723ca53b2 100644 --- a/modules/yup_gui/yup_gui.h +++ b/modules/yup_gui/yup_gui.h @@ -162,6 +162,13 @@ //============================================================================== +#include "layout/yup_FlexItem.h" +#include "layout/yup_FlexBox.h" +#include "layout/yup_GridItem.h" +#include "layout/yup_Grid.h" + +//============================================================================== + #include "profiling/yup_PaintProfiler.h" //============================================================================== diff --git a/modules/yup_python/bindings/yup_YupAudioDevices_bindings.cpp b/modules/yup_python/bindings/yup_YupAudioDevices_bindings.cpp new file mode 100644 index 000000000..8a9c10e37 --- /dev/null +++ b/modules/yup_python/bindings/yup_YupAudioDevices_bindings.cpp @@ -0,0 +1,224 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_YupAudioDevices_bindings.h" + +#include "../utilities/yup_PythonInterop.h" + +#define YUP_PYTHON_INCLUDE_PYBIND11_OPERATORS +#define YUP_PYTHON_INCLUDE_PYBIND11_FUNCTIONAL +#include "../utilities/yup_PyBind11Includes.h" + +//============================================================================== + +namespace yup::Bindings +{ + +namespace py = pybind11; +using namespace py::literals; + +void registerYupAudioDevicesBindings (py::module_& m) +{ + // clang-format off + + // ============================================================================================ yup::WASAPIDeviceMode + + py::enum_ (m, "WASAPIDeviceMode") + .value ("shared", WASAPIDeviceMode::shared) + .value ("exclusive", WASAPIDeviceMode::exclusive) + .value ("sharedLowLatency", WASAPIDeviceMode::sharedLowLatency) + .export_values(); + + // ============================================================================================ yup::AudioIODeviceCallbackContext + + py::class_ (m, "AudioIODeviceCallbackContext") + .def (py::init<>()) + .def_readwrite ("hostTimeNs", &AudioIODeviceCallbackContext::hostTimeNs); + + // ============================================================================================ yup::AudioIODeviceCallback + + py::class_ (m, "AudioIODeviceCallback") + .def (py::init<>()) + .def ("audioDeviceAboutToStart", &AudioIODeviceCallback::audioDeviceAboutToStart) + .def ("audioDeviceStopped", &AudioIODeviceCallback::audioDeviceStopped) + .def ("audioDeviceError", &AudioIODeviceCallback::audioDeviceError); + + // ============================================================================================ yup::AudioIODevice + + py::class_ (m, "AudioIODevice") + .def ("getName", &AudioIODevice::getName) + .def ("getTypeName", &AudioIODevice::getTypeName) + .def ("getOutputChannelNames", &AudioIODevice::getOutputChannelNames) + .def ("getInputChannelNames", &AudioIODevice::getInputChannelNames) + .def ("getDefaultOutputChannels", &AudioIODevice::getDefaultOutputChannels) + .def ("getDefaultInputChannels", &AudioIODevice::getDefaultInputChannels) + .def ("getAvailableSampleRates", &AudioIODevice::getAvailableSampleRates) + .def ("getAvailableBufferSizes", &AudioIODevice::getAvailableBufferSizes) + .def ("getDefaultBufferSize", &AudioIODevice::getDefaultBufferSize) + .def ("open", &AudioIODevice::open) + .def ("close", &AudioIODevice::close) + .def ("isOpen", &AudioIODevice::isOpen) + .def ("start", &AudioIODevice::start) + .def ("stop", &AudioIODevice::stop) + .def ("isPlaying", &AudioIODevice::isPlaying) + .def ("getLastError", &AudioIODevice::getLastError) + .def ("getCurrentBufferSizeSamples", &AudioIODevice::getCurrentBufferSizeSamples) + .def ("getCurrentSampleRate", &AudioIODevice::getCurrentSampleRate) + .def ("getCurrentBitDepth", &AudioIODevice::getCurrentBitDepth) + .def ("getActiveOutputChannels", &AudioIODevice::getActiveOutputChannels) + .def ("getActiveInputChannels", &AudioIODevice::getActiveInputChannels) + .def ("getOutputLatencyInSamples", &AudioIODevice::getOutputLatencyInSamples) + .def ("getInputLatencyInSamples", &AudioIODevice::getInputLatencyInSamples) + .def ("hasControlPanel", &AudioIODevice::hasControlPanel) + .def ("showControlPanel", &AudioIODevice::showControlPanel) + .def ("setAudioPreprocessingEnabled", &AudioIODevice::setAudioPreprocessingEnabled) + .def ("getXRunCount", &AudioIODevice::getXRunCount) + .def ("__repr__", [] (const AudioIODevice& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, typeid (self).name(), 1) + << " name=\"" << self.getName() << "\"" + << " type=\"" << self.getTypeName() << "\">"; + return result; + }); + + // ============================================================================================ yup::AudioDeviceManager::AudioDeviceSetup + + py::class_ (m, "AudioDeviceSetup") + .def (py::init<>()) + .def_readwrite ("outputDeviceName", &AudioDeviceManager::AudioDeviceSetup::outputDeviceName) + .def_readwrite ("inputDeviceName", &AudioDeviceManager::AudioDeviceSetup::inputDeviceName) + .def_readwrite ("sampleRate", &AudioDeviceManager::AudioDeviceSetup::sampleRate) + .def_readwrite ("bufferSize", &AudioDeviceManager::AudioDeviceSetup::bufferSize) + .def_readwrite ("inputChannels", &AudioDeviceManager::AudioDeviceSetup::inputChannels) + .def_readwrite ("useDefaultInputChannels", &AudioDeviceManager::AudioDeviceSetup::useDefaultInputChannels) + .def_readwrite ("outputChannels", &AudioDeviceManager::AudioDeviceSetup::outputChannels) + .def_readwrite ("useDefaultOutputChannels", &AudioDeviceManager::AudioDeviceSetup::useDefaultOutputChannels) + .def ("__eq__", &AudioDeviceManager::AudioDeviceSetup::operator==) + .def ("__ne__", &AudioDeviceManager::AudioDeviceSetup::operator!=); + + // ============================================================================================ yup::AudioDeviceManager::LevelMeter + + py::class_> (m, "LevelMeter") + .def ("getCurrentLevel", &AudioDeviceManager::LevelMeter::getCurrentLevel); + + // ============================================================================================ yup::AudioDeviceManager + + py::class_ (m, "AudioDeviceManager") + .def (py::init<>()) + .def ("initialise", [] (AudioDeviceManager& self, + int numInputChannelsNeeded, + int numOutputChannelsNeeded, + const XmlElement* savedState, + bool selectDefaultDeviceOnFailure, + const String& preferredDefaultDeviceName, + const AudioDeviceManager::AudioDeviceSetup* preferredSetupOptions) + { + return self.initialise (numInputChannelsNeeded, + numOutputChannelsNeeded, + savedState, + selectDefaultDeviceOnFailure, + preferredDefaultDeviceName, + preferredSetupOptions); + }, + "numInputChannelsNeeded"_a, + "numOutputChannelsNeeded"_a, + "savedState"_a = nullptr, + "selectDefaultDeviceOnFailure"_a = true, + "preferredDefaultDeviceName"_a = String(), + "preferredSetupOptions"_a = nullptr) + .def ("initialiseWithDefaultDevices", &AudioDeviceManager::initialiseWithDefaultDevices, + "numInputChannelsNeeded"_a, "numOutputChannelsNeeded"_a) + .def ("createStateXml", &AudioDeviceManager::createStateXml) + .def ("getAudioDeviceSetup", [] (AudioDeviceManager& self) + { + return self.getAudioDeviceSetup(); + }) + .def ("setAudioDeviceSetup", &AudioDeviceManager::setAudioDeviceSetup, + "newSetup"_a, "treatAsChosenDevice"_a) + .def ("getCurrentAudioDevice", &AudioDeviceManager::getCurrentAudioDevice, + py::return_value_policy::reference) + .def ("getCurrentAudioDeviceType", &AudioDeviceManager::getCurrentAudioDeviceType) + .def ("getCurrentDeviceTypeObject", &AudioDeviceManager::getCurrentDeviceTypeObject, + py::return_value_policy::reference) + .def ("setCurrentAudioDeviceType", &AudioDeviceManager::setCurrentAudioDeviceType, + "type"_a, "treatAsChosenDevice"_a) + .def ("closeAudioDevice", &AudioDeviceManager::closeAudioDevice) + .def ("restartLastAudioDevice", &AudioDeviceManager::restartLastAudioDevice) + .def ("addAudioCallback", &AudioDeviceManager::addAudioCallback) + .def ("removeAudioCallback", &AudioDeviceManager::removeAudioCallback) + .def ("getCpuUsage", &AudioDeviceManager::getCpuUsage) + .def ("playTestSound", &AudioDeviceManager::playTestSound) + .def ("getInputLevelGetter", &AudioDeviceManager::getInputLevelGetter) + .def ("getOutputLevelGetter", &AudioDeviceManager::getOutputLevelGetter) + .def ("__repr__", [] (const AudioDeviceManager& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, typeid (self).name(), 1) + << " type=\"" << self.getCurrentAudioDeviceType() << "\">"; + return result; + }); + + // ============================================================================================ yup::AudioSourcePlayer + + py::class_ (m, "AudioSourcePlayer") + .def (py::init<>()) + .def ("setSource", [] (AudioSourcePlayer& self, AudioSource* source) + { + self.setSource (source); + }, "newSource"_a) + .def ("getCurrentSource", [] (AudioSourcePlayer& self) -> py::object + { + auto* src = self.getCurrentSource(); + if (src == nullptr) + return py::none(); + return py::cast (src, py::return_value_policy::reference); + }) + .def ("setGain", &AudioSourcePlayer::setGain) + .def ("getGain", &AudioSourcePlayer::getGain) + .def ("prepareToPlay", &AudioSourcePlayer::prepareToPlay); + + // ============================================================================================ yup::AudioTransportSource + + py::class_ (m, "AudioTransportSource") + .def (py::init<>()) + .def ("setSource", &AudioTransportSource::setSource, + "newSource"_a, + "readAheadBufferSize"_a = 0, + "readAheadThread"_a = nullptr, + "sourceSampleRateToCorrectFor"_a = 0.0, + "maxNumChannels"_a = 2) + .def ("setPosition", &AudioTransportSource::setPosition) + .def ("getCurrentPosition", &AudioTransportSource::getCurrentPosition) + .def ("getLengthInSeconds", &AudioTransportSource::getLengthInSeconds) + .def ("hasStreamFinished", &AudioTransportSource::hasStreamFinished) + .def ("start", &AudioTransportSource::start) + .def ("stop", &AudioTransportSource::stop) + .def ("isPlaying", &AudioTransportSource::isPlaying) + .def ("setGain", &AudioTransportSource::setGain) + .def ("getGain", &AudioTransportSource::getGain); + + // clang-format on +} + +} // namespace yup::Bindings diff --git a/modules/yup_python/bindings/yup_YupAudioDevices_bindings.h b/modules/yup_python/bindings/yup_YupAudioDevices_bindings.h new file mode 100644 index 000000000..97cc48c63 --- /dev/null +++ b/modules/yup_python/bindings/yup_YupAudioDevices_bindings.h @@ -0,0 +1,80 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#if ! YUP_MODULE_AVAILABLE_yup_audio_devices +#error This binding file requires adding the yup_audio_devices module in the project +#else +#include +#endif + +#include "yup_YupCore_bindings.h" +#include "yup_YupEvents_bindings.h" +#include "yup_YupAudioBasics_bindings.h" + +#define YUP_PYTHON_INCLUDE_PYBIND11_OPERATORS +#define YUP_PYTHON_INCLUDE_PYBIND11_STL +#include "../utilities/yup_PyBind11Includes.h" + +namespace yup::Bindings +{ + +//============================================================================== + +void registerYupAudioDevicesBindings (pybind11::module_& m); + +//============================================================================== + +struct PyAudioIODeviceCallback : AudioIODeviceCallback +{ + // NOTE: audioDeviceIOCallbackWithContext uses raw float* pointer arrays + // that pybind11 cannot marshal. Python subclasses should use AudioSource + + // AudioSourcePlayer for custom audio processing instead. This method falls + // through to the C++ default (no-op). + void audioDeviceIOCallbackWithContext (const float* const* inputChannelData, + int numInputChannels, + float* const* outputChannelData, + int numOutputChannels, + int numSamples, + const AudioIODeviceCallbackContext& context) override + { + AudioIODeviceCallback::audioDeviceIOCallbackWithContext ( + inputChannelData, numInputChannels, outputChannelData, numOutputChannels, numSamples, context); + } + + void audioDeviceAboutToStart (AudioIODevice* device) override + { + PYBIND11_OVERRIDE_PURE (void, AudioIODeviceCallback, audioDeviceAboutToStart, device); + } + + void audioDeviceStopped() override + { + PYBIND11_OVERRIDE_PURE (void, AudioIODeviceCallback, audioDeviceStopped); + } + + void audioDeviceError (const String& errorMessage) override + { + PYBIND11_OVERRIDE (void, AudioIODeviceCallback, audioDeviceError, errorMessage); + } +}; + +} // namespace yup::Bindings diff --git a/modules/yup_python/bindings/yup_YupAudioFormats_bindings.cpp b/modules/yup_python/bindings/yup_YupAudioFormats_bindings.cpp new file mode 100644 index 000000000..16b725be5 --- /dev/null +++ b/modules/yup_python/bindings/yup_YupAudioFormats_bindings.cpp @@ -0,0 +1,139 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_YupAudioFormats_bindings.h" + +#include "../utilities/yup_PythonInterop.h" + +#define YUP_PYTHON_INCLUDE_PYBIND11_OPERATORS +#define YUP_PYTHON_INCLUDE_PYBIND11_FUNCTIONAL +#include "../utilities/yup_PyBind11Includes.h" + +//============================================================================== + +namespace yup::Bindings +{ + +namespace py = pybind11; +using namespace py::literals; + +void registerYupAudioFormatsBindings (py::module_& m) +{ + // clang-format off + + // ============================================================================================ yup::AudioFormatType + + py::enum_ (m, "AudioFormatType") + .value ("wav", AudioFormatType::wav) + .value ("mp3", AudioFormatType::mp3) + .value ("flac", AudioFormatType::flac) + .value ("ogg", AudioFormatType::ogg) + .value ("opus", AudioFormatType::opus) + .value ("coreAudio", AudioFormatType::coreAudio) + .value ("windowsMedia", AudioFormatType::windowsMedia) + .value ("all", AudioFormatType::all) + .export_values(); + + // ============================================================================================ yup::AudioFormatManager + + py::class_ (m, "AudioFormatManager") + .def (py::init<>()) + .def ("registerDefaultFormats", &AudioFormatManager::registerDefaultFormats, + "types"_a = AudioFormatType::all) + .def ("registerFormat", &AudioFormatManager::registerFormat) + .def ("createReaderFor", &AudioFormatManager::createReaderFor) + .def ("__repr__", [] (const AudioFormatManager& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, typeid (self).name(), 1) + << " object at " << String::formatted ("%p", std::addressof (self)) << ">"; + return result; + }); + + // ============================================================================================ yup::AudioFormatReader + + py::class_ (m, "AudioFormatReader") + .def ("getFormatName", &AudioFormatReader::getFormatName) + .def ("read", [](AudioFormatReader& self, + AudioBuffer* buffer, + int startSampleInDestBuffer, + int numSamples, + int64 readerStartSample, + bool useReaderLeftChan, + bool useReaderRightChan) + { + return self.read (buffer, startSampleInDestBuffer, numSamples, + readerStartSample, useReaderLeftChan, useReaderRightChan); + }, + "buffer"_a, + "startSampleInDestBuffer"_a, + "numSamples"_a, + "readerStartSample"_a, + "useReaderLeftChan"_a, + "useReaderRightChan"_a) + .def_readonly ("sampleRate", &AudioFormatReader::sampleRate) + .def_readonly ("bitsPerSample", &AudioFormatReader::bitsPerSample) + .def_readonly ("lengthInSamples", &AudioFormatReader::lengthInSamples) + .def_readonly ("numChannels", &AudioFormatReader::numChannels) + .def_readonly ("usesFloatingPointData", &AudioFormatReader::usesFloatingPointData) + .def_readonly ("metadataValues", &AudioFormatReader::metadataValues) + .def ("__repr__", [] (const AudioFormatReader& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, typeid (self).name(), 1) + << " format=\"" << self.getFormatName() << "\"" + << " sampleRate=" << self.sampleRate + << " numChannels=" << self.numChannels + << " lengthInSamples=" << self.lengthInSamples << ">"; + return result; + }); + + // ============================================================================================ yup::AudioFormatReaderSource + + py::class_ (m, "AudioFormatReaderSource") + .def (py::init ([] (AudioFormatReader* reader, bool deleteReaderWhenThisIsDeleted) + { + // Transfer ownership: release the Python-owned reader to C++ + return std::make_unique (reader, deleteReaderWhenThisIsDeleted); + }), + "sourceReader"_a, "deleteReaderWhenThisIsDeleted"_a = true) + .def ("getAudioFormatReader", [] (AudioFormatReaderSource& self) -> AudioFormatReader* + { + if (auto* reader = self.getAudioFormatReader()) + return reader; + return nullptr; + }, py::return_value_policy::reference) + .def ("__repr__", [] (const AudioFormatReaderSource& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, typeid (self).name(), 1) + << " totalLength=" << self.getTotalLength() + << " looping=" << (self.isLooping() ? "true" : "false") << ">"; + return result; + }); + + // clang-format on +} + +} // namespace yup::Bindings diff --git a/modules/yup_python/bindings/yup_YupAudioFormats_bindings.h b/modules/yup_python/bindings/yup_YupAudioFormats_bindings.h new file mode 100644 index 000000000..2d9b6a921 --- /dev/null +++ b/modules/yup_python/bindings/yup_YupAudioFormats_bindings.h @@ -0,0 +1,44 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#if ! YUP_MODULE_AVAILABLE_yup_audio_formats +#error This binding file requires adding the yup_audio_formats module in the project +#else +#include +#endif + +#include "yup_YupCore_bindings.h" +#include "yup_YupAudioBasics_bindings.h" + +#define YUP_PYTHON_INCLUDE_PYBIND11_OPERATORS +#define YUP_PYTHON_INCLUDE_PYBIND11_STL +#include "../utilities/yup_PyBind11Includes.h" + +namespace yup::Bindings +{ + +//============================================================================== + +void registerYupAudioFormatsBindings (pybind11::module_& m); + +} // namespace yup::Bindings diff --git a/modules/yup_python/bindings/yup_YupGraphics_bindings.cpp b/modules/yup_python/bindings/yup_YupGraphics_bindings.cpp index 2420c013a..3f2ba71c5 100644 --- a/modules/yup_python/bindings/yup_YupGraphics_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupGraphics_bindings.cpp @@ -1859,6 +1859,8 @@ void registerYupGraphicsBindings (py::module_& m) // Image operations .def ("drawImageAt", &Graphics::drawImageAt) + .def ("drawImage", &Graphics::drawImage) + .def ("drawTexture", &Graphics::drawTexture) // Text operations .def ("fillFittedText", py::overload_cast&, Justification> (&Graphics::fillFittedText)) @@ -1871,6 +1873,7 @@ void registerYupGraphicsBindings (py::module_& m) .def ("getContextScale", &Graphics::getContextScale) .def ("getFactory", &Graphics::getFactory, py::return_value_policy::reference_internal) .def ("getRenderer", &Graphics::getRenderer, py::return_value_policy::reference_internal) + .def ("getGraphicsContext", &Graphics::getGraphicsContext, py::return_value_policy::reference) ; // ============================================================================================ yup::Colors diff --git a/modules/yup_python/bindings/yup_YupGui_bindings.cpp b/modules/yup_python/bindings/yup_YupGui_bindings.cpp index 5120a1d71..caac732ae 100644 --- a/modules/yup_python/bindings/yup_YupGui_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupGui_bindings.cpp @@ -570,6 +570,275 @@ void registerYupGuiBindings (py::module_& m) return std::addressof (self); }, py::return_value_policy::reference); #endif + + // ============================================================================================ yup::Button + + py::class_> (m, "Button") + .def (py::init(), "componentID"_a = StringRef()) + .def ("isButtonOver", &Button::isButtonOver) + .def ("isButtonDown", &Button::isButtonDown) + .def_readwrite ("onClick", &Button::onClick) + .def ("paintButton", &Button::paintButton); + + // ============================================================================================ yup::TextButton + + py::class_ (m, "TextButton") + .def (py::init(), "componentID"_a = StringRef()) + .def ("getButtonText", &TextButton::getButtonText) + .def ("setButtonText", &TextButton::setButtonText); + + // ============================================================================================ yup::ToggleButton + + py::class_ (m, "ToggleButton") + .def (py::init(), "componentID"_a = StringRef()) + .def ("getToggleState", &ToggleButton::getToggleState) + .def ("setToggleState", &ToggleButton::setToggleState, + "shouldBeToggled"_a, "notification"_a = sendNotification) + .def ("getButtonText", &ToggleButton::getButtonText) + .def ("setButtonText", &ToggleButton::setButtonText); + + // ============================================================================================ yup::Slider::SliderType + + py::enum_ (m, "SliderType") + .value ("LinearHorizontal", Slider::LinearHorizontal) + .value ("LinearVertical", Slider::LinearVertical) + .value ("LinearBarHorizontal", Slider::LinearBarHorizontal) + .value ("LinearBarVertical", Slider::LinearBarVertical) + .value ("Rotary", Slider::Rotary) + .value ("RotaryHorizontalDrag", Slider::RotaryHorizontalDrag) + .value ("RotaryVerticalDrag", Slider::RotaryVerticalDrag) + .value ("IncDecButtons", Slider::IncDecButtons) + .value ("TwoValueHorizontal", Slider::TwoValueHorizontal) + .value ("TwoValueVertical", Slider::TwoValueVertical) + .value ("ThreeValueHorizontal", Slider::ThreeValueHorizontal) + .value ("ThreeValueVertical", Slider::ThreeValueVertical) + .export_values(); + + // Make SliderType accessible as Slider.SliderType via the class + py::class_ (m, "Slider") + .def (py::init(), + "sliderType"_a, "componentID"_a = StringRef()) + .def (py::init(), + "sliderType"_a) + .def ("setValue", &Slider::setValue, + "newValue"_a, "notification"_a = sendNotification) + .def ("getValue", &Slider::getValue) + .def ("setValueNormalised", &Slider::setValueNormalised, + "newValue"_a, "notification"_a = sendNotification) + .def ("getValueNormalised", &Slider::getValueNormalised) + .def ("setMinValue", &Slider::setMinValue, + "newMinValue"_a, "notification"_a = sendNotification, "allowNudgingOfOtherValues"_a = false) + .def ("getMinValue", &Slider::getMinValue) + .def ("setMaxValue", &Slider::setMaxValue, + "newMaxValue"_a, "notification"_a = sendNotification, "allowNudgingOfOtherValues"_a = false) + .def ("getMaxValue", &Slider::getMaxValue) + .def ("setRange", py::overload_cast (&Slider::setRange), + "minValue"_a, "maxValue"_a, "stepSize"_a = 0.0) + .def ("setSkewFactor", &Slider::setSkewFactor) + .def ("setNumDecimalPlacesToDisplay", &Slider::setNumDecimalPlacesToDisplay) + .def ("setTextBoxStyle", &Slider::setTextBoxStyle, + "position"_a, "isReadOnly"_a = false, "textEntryBoxWidth"_a = 80, "textEntryBoxHeight"_a = 20) + .def_readwrite ("onValueChanged", &Slider::onValueChanged) + .def_readwrite ("onMinValueChanged", &Slider::onMinValueChanged) + .def_readwrite ("onMaxValueChanged", &Slider::onMaxValueChanged) + .def ("__repr__", [] (const Slider& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, typeid (self).name(), 1) + << " value=" << self.getValue() << ">"; + return result; + }); + + // ============================================================================================ yup::Slider::TextEntryBoxPosition + + py::enum_ (m, "TextEntryBoxPosition") + .value ("NoTextBox", Slider::NoTextBox) + .value ("TextBoxLeft", Slider::TextBoxLeft) + .value ("TextBoxRight", Slider::TextBoxRight) + .value ("TextBoxAbove", Slider::TextBoxAbove) + .value ("TextBoxBelow", Slider::TextBoxBelow) + .export_values(); + + // ============================================================================================ yup::Label + + py::class_ (m, "Label") + .def (py::init(), "componentID"_a = StringRef()) + .def ("getText", &Label::getText) + .def ("setText", &Label::setText, + "newText"_a, "notification"_a = sendNotification) + .def ("__repr__", [] (const Label& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, typeid (self).name(), 1) + << " text=\"" << self.getText() << "\">"; + return result; + }); + + // ============================================================================================ yup::FlexItem::AlignSelf + + py::enum_ (m, "FlexAlignSelf") + .value ("autoAlign", FlexItem::AlignSelf::autoAlign) + .value ("flexStart", FlexItem::AlignSelf::flexStart) + .value ("flexEnd", FlexItem::AlignSelf::flexEnd) + .value ("center", FlexItem::AlignSelf::center) + .value ("stretch", FlexItem::AlignSelf::stretch) + .export_values(); + + // ============================================================================================ yup::FlexItem + + py::class_ (m, "FlexItem") + .def (py::init<>()) + .def (py::init()) + .def (py::init()) + .def (py::init()) + .def (py::init()) + .def (py::init()) + .def_readwrite ("associatedComponent", &FlexItem::associatedComponent) + .def_readwrite ("flexGrow", &FlexItem::flexGrow) + .def_readwrite ("flexShrink", &FlexItem::flexShrink) + .def_readwrite ("flexBasis", &FlexItem::flexBasis) + .def_readwrite ("minWidth", &FlexItem::minWidth) + .def_readwrite ("minHeight", &FlexItem::minHeight) + .def_readwrite ("maxWidth", &FlexItem::maxWidth) + .def_readwrite ("maxHeight", &FlexItem::maxHeight) + .def_readwrite ("width", &FlexItem::width) + .def_readwrite ("height", &FlexItem::height) + .def_readwrite ("alignSelf", &FlexItem::alignSelf) + .def_readwrite ("marginLeft", &FlexItem::marginLeft) + .def_readwrite ("marginRight", &FlexItem::marginRight) + .def_readwrite ("marginTop", &FlexItem::marginTop) + .def_readwrite ("marginBottom", &FlexItem::marginBottom) + .def_readwrite ("order", &FlexItem::order) + .def ("withFlex", &FlexItem::withFlex) + .def ("withWidth", &FlexItem::withWidth) + .def ("withHeight", &FlexItem::withHeight) + .def ("withMinWidth", &FlexItem::withMinWidth) + .def ("withMinHeight", &FlexItem::withMinHeight) + .def ("withMaxWidth", &FlexItem::withMaxWidth) + .def ("withMaxHeight", &FlexItem::withMaxHeight) + .def ("withMargin", &FlexItem::withMargin) + .def ("withAlignSelf", &FlexItem::withAlignSelf) + .def ("withOrder", &FlexItem::withOrder); + + // ============================================================================================ yup::FlexBox + + py::enum_ (m, "FlexDirection") + .value ("row", FlexBox::Direction::row) + .value ("rowReverse", FlexBox::Direction::rowReverse) + .value ("column", FlexBox::Direction::column) + .value ("columnReverse", FlexBox::Direction::columnReverse) + .export_values(); + + py::enum_ (m, "FlexWrap") + .value ("noWrap", FlexBox::Wrap::noWrap) + .value ("wrap", FlexBox::Wrap::wrap) + .value ("wrapReverse", FlexBox::Wrap::wrapReverse) + .export_values(); + + py::enum_ (m, "FlexJustifyContent") + .value ("flexStart", FlexBox::JustifyContent::flexStart) + .value ("flexEnd", FlexBox::JustifyContent::flexEnd) + .value ("center", FlexBox::JustifyContent::center) + .value ("spaceBetween", FlexBox::JustifyContent::spaceBetween) + .value ("spaceAround", FlexBox::JustifyContent::spaceAround) + .export_values(); + + py::enum_ (m, "FlexAlignItems") + .value ("flexStart", FlexBox::AlignItems::flexStart) + .value ("flexEnd", FlexBox::AlignItems::flexEnd) + .value ("center", FlexBox::AlignItems::center) + .value ("stretch", FlexBox::AlignItems::stretch) + .export_values(); + + py::enum_ (m, "FlexAlignContent") + .value ("flexStart", FlexBox::AlignContent::flexStart) + .value ("flexEnd", FlexBox::AlignContent::flexEnd) + .value ("center", FlexBox::AlignContent::center) + .value ("spaceBetween", FlexBox::AlignContent::spaceBetween) + .value ("spaceAround", FlexBox::AlignContent::spaceAround) + .value ("stretch", FlexBox::AlignContent::stretch) + .export_values(); + + py::class_ (m, "FlexBox") + .def (py::init<>()) + .def (py::init()) + .def (py::init()) + .def_readwrite ("flexDirection", &FlexBox::flexDirection) + .def_readwrite ("flexWrap", &FlexBox::flexWrap) + .def_readwrite ("alignItems", &FlexBox::alignItems) + .def_readwrite ("justifyContent", &FlexBox::justifyContent) + .def_readwrite ("alignContent", &FlexBox::alignContent) + .def_readwrite ("gap", &FlexBox::gap) + .def_readwrite ("items", &FlexBox::items) + .def ("performLayout", py::overload_cast> (&FlexBox::performLayout)) + .def ("performLayout", py::overload_cast> (&FlexBox::performLayout)); + + // ============================================================================================ yup::GridItem::AlignSelf + + py::enum_ (m, "GridAlignSelf") + .value ("autoAlign", GridItem::AlignSelf::autoAlign) + .value ("flexStart", GridItem::AlignSelf::flexStart) + .value ("flexEnd", GridItem::AlignSelf::flexEnd) + .value ("center", GridItem::AlignSelf::center) + .value ("stretch", GridItem::AlignSelf::stretch) + .export_values(); + + // ============================================================================================ yup::GridItem + + py::class_ (m, "GridItem") + .def (py::init<>()) + .def (py::init()) + .def (py::init()) + .def_readwrite ("associatedComponent", &GridItem::associatedComponent) + .def_readwrite ("column", &GridItem::column) + .def_readwrite ("row", &GridItem::row) + .def_readwrite ("columnSpan", &GridItem::columnSpan) + .def_readwrite ("rowSpan", &GridItem::rowSpan) + .def_readwrite ("justifySelf", &GridItem::justifySelf) + .def_readwrite ("alignSelf", &GridItem::alignSelf) + .def_readwrite ("marginLeft", &GridItem::marginLeft) + .def_readwrite ("marginRight", &GridItem::marginRight) + .def_readwrite ("marginTop", &GridItem::marginTop) + .def_readwrite ("marginBottom", &GridItem::marginBottom) + .def ("withColumn", &GridItem::withColumn) + .def ("withRow", &GridItem::withRow) + .def ("withColumnSpan", &GridItem::withColumnSpan) + .def ("withRowSpan", &GridItem::withRowSpan) + .def ("withMargin", &GridItem::withMargin); + + // ============================================================================================ yup::Grid + + py::enum_ (m, "GridAlignItems") + .value ("flexStart", Grid::AlignItems::flexStart) + .value ("flexEnd", Grid::AlignItems::flexEnd) + .value ("center", Grid::AlignItems::center) + .value ("stretch", Grid::AlignItems::stretch) + .export_values(); + + py::class_ (m, "TrackInfo") + .def_static ("px", &Grid::TrackInfo::px) + .def_static ("fr", &Grid::TrackInfo::fr) + .def_static ("auto_", &Grid::TrackInfo::auto_) + .def_readwrite ("pixelSize", &Grid::TrackInfo::pixelSize) + .def_readwrite ("fraction", &Grid::TrackInfo::fraction) + .def_readwrite ("isAuto", &Grid::TrackInfo::isAuto); + + py::class_ (m, "Grid") + .def (py::init<>()) + .def_readwrite ("templateColumns", &Grid::templateColumns) + .def_readwrite ("templateRows", &Grid::templateRows) + .def_readwrite ("autoRows", &Grid::autoRows) + .def_readwrite ("autoColumns", &Grid::autoColumns) + .def_readwrite ("columnGap", &Grid::columnGap) + .def_readwrite ("rowGap", &Grid::rowGap) + .def_readwrite ("justifyItems", &Grid::justifyItems) + .def_readwrite ("alignItems", &Grid::alignItems) + .def_readwrite ("items", &Grid::items) + .def ("performLayout", py::overload_cast> (&Grid::performLayout)) + .def ("performLayout", py::overload_cast> (&Grid::performLayout)); } } // namespace Bindings diff --git a/modules/yup_python/bindings/yup_YupGui_bindings.h b/modules/yup_python/bindings/yup_YupGui_bindings.h index f3d243934..9a526e3ba 100644 --- a/modules/yup_python/bindings/yup_YupGui_bindings.h +++ b/modules/yup_python/bindings/yup_YupGui_bindings.h @@ -459,4 +459,39 @@ struct PyDocumentWindow : PyComponent //} }; +// ============================================================================================ + +template +struct PyButton : PyComponent +{ + using PyComponent::PyComponent; + + void paintButton (yup::Graphics& g) override + { + PYBIND11_OVERRIDE_PURE (void, Base, paintButton, g); + } +}; + +// ============================================================================================ + +struct PySlider : yup::Slider +{ + using Slider::Slider; + + void valueChanged() override + { + PYBIND11_OVERRIDE (void, yup::Slider, valueChanged); + } + + void minValueChanged() override + { + PYBIND11_OVERRIDE (void, yup::Slider, minValueChanged); + } + + void maxValueChanged() override + { + PYBIND11_OVERRIDE (void, yup::Slider, maxValueChanged); + } +}; + } // namespace yup::Bindings diff --git a/modules/yup_python/bindings/yup_YupRhi_bindings.cpp b/modules/yup_python/bindings/yup_YupRhi_bindings.cpp new file mode 100644 index 000000000..c2b42f7f2 --- /dev/null +++ b/modules/yup_python/bindings/yup_YupRhi_bindings.cpp @@ -0,0 +1,368 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_YupRhi_bindings.h" + +#include "../utilities/yup_PythonInterop.h" + +#define YUP_PYTHON_INCLUDE_PYBIND11_OPERATORS +#define YUP_PYTHON_INCLUDE_PYBIND11_FUNCTIONAL +#include "../utilities/yup_PyBind11Includes.h" + +//============================================================================== + +namespace yup::Bindings +{ + +namespace py = pybind11; +using namespace py::literals; + +void registerYupRhiBindings (py::module_& m) +{ + // clang-format off + + // ============================================================================================ yup::GraphicsContext enums + + py::enum_ (m, "GraphicsApi") + .value ("Headless", GraphicsContext::Api::Headless) + .value ("OpenGL", GraphicsContext::Api::OpenGL) + .value ("OpenGLES", GraphicsContext::Api::OpenGLES) + .value ("Direct3D", GraphicsContext::Api::Direct3D) + .value ("Metal", GraphicsContext::Api::Metal) + .value ("WebGPU", GraphicsContext::Api::WebGPU) + ; + + // ============================================================================================ yup::GraphicsContext::Options + + py::class_ (m, "GraphicsContextOptions") + .def (py::init<>()) + .def_readwrite ("retinaDisplay", &GraphicsContext::Options::retinaDisplay) + .def_readwrite ("readableFramebuffer", &GraphicsContext::Options::readableFramebuffer) + .def_readwrite ("synchronousShaderCompilations", &GraphicsContext::Options::synchronousShaderCompilations) + .def_readwrite ("enableReadPixels", &GraphicsContext::Options::enableReadPixels) + .def_readwrite ("disableRasterOrdering", &GraphicsContext::Options::disableRasterOrdering) + .def_readwrite ("allowHeadlessRendering", &GraphicsContext::Options::allowHeadlessRendering); + + // ============================================================================================ yup::GraphicsContext + + py::class_ (m, "GraphicsContext") + .def_static ("createContext", &GraphicsContext::createContext, + "graphicsApi"_a, "options"_a) + .def ("getApi", &GraphicsContext::getApi) + .def ("isGpuAvailable", &GraphicsContext::isGpuAvailable); + + // ============================================================================================ GPU enums + + py::enum_ (m, "GpuShaderLanguage") + .value ("wgsl", GpuShaderLanguage::wgsl) + .value ("glsl", GpuShaderLanguage::glsl) + .value ("msl", GpuShaderLanguage::msl) + .value ("hlsl", GpuShaderLanguage::hlsl) + ; + + py::enum_ (m, "GpuVertexFormat") + .value ("float1", GpuVertexFormat::float1) + .value ("float2", GpuVertexFormat::float2) + .value ("float3", GpuVertexFormat::float3) + .value ("float4", GpuVertexFormat::float4) + .value ("uint8x4", GpuVertexFormat::uint8x4) + .value ("snorm8x4", GpuVertexFormat::snorm8x4) + .value ("unorm8x4", GpuVertexFormat::unorm8x4) + ; + + py::enum_ (m, "GpuVertexStepMode") + .value ("vertex", GpuVertexStepMode::vertex) + .value ("instance", GpuVertexStepMode::instance) + ; + + py::enum_ (m, "GpuPrimitiveTopology") + .value ("pointList", GpuPrimitiveTopology::pointList) + .value ("lineList", GpuPrimitiveTopology::lineList) + .value ("lineStrip", GpuPrimitiveTopology::lineStrip) + .value ("triangleList", GpuPrimitiveTopology::triangleList) + .value ("triangleStrip", GpuPrimitiveTopology::triangleStrip) + ; + + py::enum_ (m, "GpuIndexFormat") + .value ("none", GpuIndexFormat::none) + .value ("uint16", GpuIndexFormat::uint16) + .value ("uint32", GpuIndexFormat::uint32) + ; + + py::enum_ (m, "GpuCullMode") + .value ("none", GpuCullMode::none) + .value ("front", GpuCullMode::front) + .value ("back", GpuCullMode::back) + ; + + py::enum_ (m, "GpuFaceWinding") + .value ("clockwise", GpuFaceWinding::clockwise) + .value ("counterClockwise", GpuFaceWinding::counterClockwise) + ; + + py::enum_ (m, "GpuCompareFunction") + .value ("never", GpuCompareFunction::never) + .value ("less", GpuCompareFunction::less) + .value ("equal", GpuCompareFunction::equal) + .value ("lessEqual", GpuCompareFunction::lessEqual) + .value ("greater", GpuCompareFunction::greater) + .value ("notEqual", GpuCompareFunction::notEqual) + .value ("greaterEqual", GpuCompareFunction::greaterEqual) + .value ("always", GpuCompareFunction::always) + ; + + py::enum_ (m, "GpuStencilOp") + .value ("keep", GpuStencilOp::keep) + .value ("zero", GpuStencilOp::zero) + .value ("replace", GpuStencilOp::replace) + .value ("incrementClamp", GpuStencilOp::incrementClamp) + .value ("decrementClamp", GpuStencilOp::decrementClamp) + .value ("invert", GpuStencilOp::invert) + .value ("incrementWrap", GpuStencilOp::incrementWrap) + .value ("decrementWrap", GpuStencilOp::decrementWrap) + ; + + py::enum_ (m, "GpuBlendFactor") + .value ("zero", GpuBlendFactor::zero) + .value ("one", GpuBlendFactor::one) + .value ("srcColor", GpuBlendFactor::srcColor) + .value ("oneMinusSrcColor", GpuBlendFactor::oneMinusSrcColor) + .value ("srcAlpha", GpuBlendFactor::srcAlpha) + .value ("oneMinusSrcAlpha", GpuBlendFactor::oneMinusSrcAlpha) + .value ("dstColor", GpuBlendFactor::dstColor) + .value ("oneMinusDstColor", GpuBlendFactor::oneMinusDstColor) + .value ("dstAlpha", GpuBlendFactor::dstAlpha) + .value ("oneMinusDstAlpha", GpuBlendFactor::oneMinusDstAlpha) + ; + + py::enum_ (m, "GpuBlendOp") + .value ("add", GpuBlendOp::add) + .value ("subtract", GpuBlendOp::subtract) + .value ("reverseSubtract", GpuBlendOp::reverseSubtract) + .value ("min", GpuBlendOp::min) + .value ("max", GpuBlendOp::max) + ; + + py::enum_ (m, "GpuTextureFormat") + .value ("rgba8unorm", GpuTextureFormat::rgba8unorm) + .value ("bgra8unorm", GpuTextureFormat::bgra8unorm) + .value ("rgba16float", GpuTextureFormat::rgba16float) + .value ("depth24plusStencil8", GpuTextureFormat::depth24plusStencil8) + .value ("depth32float", GpuTextureFormat::depth32float) + ; + + py::enum_ (m, "GpuBufferType") + .value ("vertex", GpuBufferType::vertex) + .value ("index", GpuBufferType::index) + .value ("uniform", GpuBufferType::uniform) + ; + + // ============================================================================================ GPU config structs + + py::class_ (m, "GpuShaderSource") + .def (py::init<>()) + .def_readwrite ("language", &GpuShaderSource::language) + .def_readwrite ("code", &GpuShaderSource::code) + .def_readwrite ("codeSize", &GpuShaderSource::codeSize) + .def_readwrite ("entryPoint", &GpuShaderSource::entryPoint); + + py::class_ (m, "GpuVertexAttribute") + .def (py::init<>()) + .def (py::init(), + "format"_a, "offset"_a, "shaderLocation"_a) + .def_readwrite ("format", &GpuVertexAttribute::format) + .def_readwrite ("offset", &GpuVertexAttribute::offset) + .def_readwrite ("shaderLocation", &GpuVertexAttribute::shaderLocation); + + py::class_ (m, "GpuVertexBufferLayout") + .def (py::init<>()) + .def_readwrite ("stride", &GpuVertexBufferLayout::stride) + .def_readwrite ("stepMode", &GpuVertexBufferLayout::stepMode) + .def_readwrite ("attributeCount", &GpuVertexBufferLayout::attributeCount); + + py::class_ (m, "GpuBlendState") + .def (py::init<>()) + .def_readwrite ("srcColor", &GpuBlendState::srcColor) + .def_readwrite ("dstColor", &GpuBlendState::dstColor) + .def_readwrite ("colorOp", &GpuBlendState::colorOp) + .def_readwrite ("srcAlpha", &GpuBlendState::srcAlpha) + .def_readwrite ("dstAlpha", &GpuBlendState::dstAlpha) + .def_readwrite ("alphaOp", &GpuBlendState::alphaOp); + + py::class_ (m, "GpuColorTarget") + .def (py::init<>()) + .def_readwrite ("format", &GpuColorTarget::format) + .def_readwrite ("blendEnabled", &GpuColorTarget::blendEnabled) + .def_readwrite ("blend", &GpuColorTarget::blend); + + py::class_ (m, "GpuStencilFaceState") + .def (py::init<>()) + .def_readwrite ("compare", &GpuStencilFaceState::compare) + .def_readwrite ("failOp", &GpuStencilFaceState::failOp) + .def_readwrite ("depthFailOp", &GpuStencilFaceState::depthFailOp) + .def_readwrite ("passOp", &GpuStencilFaceState::passOp); + + py::class_ (m, "GpuDepthStencilState") + .def (py::init<>()) + .def_readwrite ("enabled", &GpuDepthStencilState::enabled) + .def_readwrite ("format", &GpuDepthStencilState::format) + .def_readwrite ("depthCompare", &GpuDepthStencilState::depthCompare) + .def_readwrite ("depthWriteEnabled", &GpuDepthStencilState::depthWriteEnabled); + + py::class_ (m, "GpuPipelineOptions") + .def (py::init<>()) + .def_readwrite ("topology", &GpuPipelineOptions::topology) + .def_readwrite ("indexFormat", &GpuPipelineOptions::indexFormat) + .def_readwrite ("cullMode", &GpuPipelineOptions::cullMode) + .def_readwrite ("winding", &GpuPipelineOptions::winding) + .def_readwrite ("colorTargetCount", &GpuPipelineOptions::colorTargetCount) + .def_readwrite ("depthStencil", &GpuPipelineOptions::depthStencil) + .def_readwrite ("stencilFront", &GpuPipelineOptions::stencilFront) + .def_readwrite ("stencilBack", &GpuPipelineOptions::stencilBack) + .def_readwrite ("stencilReadMask", &GpuPipelineOptions::stencilReadMask) + .def_readwrite ("stencilWriteMask", &GpuPipelineOptions::stencilWriteMask) + .def_readwrite ("sampleCount", &GpuPipelineOptions::sampleCount); + + py::class_ (m, "GpuRenderOptions") + .def (py::init<>()) + .def (py::init(), "clear"_a, "clearColor"_a) + .def_readwrite ("clear", &GpuRenderOptions::clear) + .def_readwrite ("clearColor", &GpuRenderOptions::clearColor); + + // ============================================================================================ yup::GpuTexture + + py::class_> (m, "GpuTexture") + .def ("getWidth", &GpuTexture::getWidth) + .def ("getHeight", &GpuTexture::getHeight) + .def ("isValid", &GpuTexture::isValid) + .def ("isRenderTarget", &GpuTexture::isRenderTarget) + .def ("__repr__", [] (const GpuTexture& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, typeid (self).name(), 1) + << " " << self.getWidth() << "x" << self.getHeight() << ">"; + return result; + }); + + // ============================================================================================ yup::GpuBuffer + + py::class_> (m, "GpuBuffer") + .def_static ("create", &GpuBuffer::create) + .def ("getType", &GpuBuffer::getType) + .def ("getSizeInBytes", &GpuBuffer::getSizeInBytes) + .def ("isValid", &GpuBuffer::isValid); + + // ============================================================================================ yup::GpuPipeline + + py::class_> (m, "GpuPipeline") + .def_static ("compile", &GpuPipeline::compile) +#if YUP_ENABLE_SHADER_TRANSPILER + .def_static ("compileFromGlsl", &GpuPipeline::compileFromGlsl) +#endif + .def ("isValid", [](const GpuPipeline& self) { return true; }); + + // ============================================================================================ yup::GpuPipelineCache + + py::class_ (m, "GpuPipelineCache") + .def (py::init()) + .def ("getNumEntries", &GpuPipelineCache::getNumEntries) + .def ("setMaxEntries", &GpuPipelineCache::setMaxEntries) + .def ("getMaxEntries", &GpuPipelineCache::getMaxEntries) + .def ("clear", &GpuPipelineCache::clear); + + // ============================================================================================ yup::GpuTarget + + py::class_> (m, "GpuTarget") + .def_static ("create", &GpuTarget::create) + .def ("getWidth", &GpuTarget::getWidth) + .def ("getHeight", &GpuTarget::getHeight) + .def ("asTexture", &GpuTarget::asTexture) + .def ("asImage", &GpuTarget::asImage) + .def ("__repr__", [] (const GpuTarget& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, typeid (self).name(), 1) + << " " << self.getWidth() << "x" << self.getHeight() << ">"; + return result; + }); + + // ============================================================================================ yup::GpuCanvas + + py::class_> (m, "GpuCanvas") + .def_static ("create", &GpuCanvas::create) + .def ("getWidth", &GpuCanvas::getWidth) + .def ("getHeight", &GpuCanvas::getHeight) + .def ("asTexture", &GpuCanvas::asTexture) + .def ("asImage", &GpuCanvas::asImage) + .def ("getTarget", &GpuCanvas::getTarget) + .def ("__repr__", [] (const GpuCanvas& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, typeid (self).name(), 1) + << " " << self.getWidth() << "x" << self.getHeight() << ">"; + return result; + }); + + // ============================================================================================ yup::GpuFrame (move-only, context manager) + + py::class_ (m, "GpuFrame") + .def_static ("begin", &GpuFrame::begin) + .def ("isValid", &GpuFrame::isValid) + .def ("submit", &GpuFrame::submit) + .def ("waitForGPU", &GpuFrame::waitForGPU) + .def ("__enter__", [] (GpuFrame& self) -> GpuFrame& { return self; }) + .def ("__exit__", [] (GpuFrame&, const std::optional&, + const std::optional&, + const std::optional&) { /* auto-submit on destructor */ }); + + // ============================================================================================ yup::GpuRenderPass (move-only, context manager) + + py::class_ (m, "GpuRenderPass") + .def ("isValid", &GpuRenderPass::isValid) + .def ("setPipeline", &GpuRenderPass::setPipeline) + .def ("setTexture", [] (GpuRenderPass& self, int group, int binding, GpuTexture::Ptr texture) + { + self.setTexture (group, binding, std::move (texture)); + }) + .def ("setUniformBuffer", [] (GpuRenderPass& self, int group, int binding, + py::bytes data) + { + self.setUniformBuffer (group, binding, + data.cast().data(), + data.cast().size()); + }) + .def ("setVertexBuffer", &GpuRenderPass::setVertexBuffer) + .def ("setIndexBuffer", &GpuRenderPass::setIndexBuffer) + .def ("draw", &GpuRenderPass::draw) + .def ("drawIndexed", &GpuRenderPass::drawIndexed) + .def ("finish", &GpuRenderPass::finish) + .def ("__enter__", [] (GpuRenderPass& self) -> GpuRenderPass& { return self; }) + .def ("__exit__", [] (GpuRenderPass&, const std::optional&, + const std::optional&, + const std::optional&) { /* auto-finish on destructor */ }); + + // clang-format on +} + +} // namespace yup::Bindings diff --git a/modules/yup_python/bindings/yup_YupRhi_bindings.h b/modules/yup_python/bindings/yup_YupRhi_bindings.h new file mode 100644 index 000000000..ff2f3ffb8 --- /dev/null +++ b/modules/yup_python/bindings/yup_YupRhi_bindings.h @@ -0,0 +1,44 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#if ! YUP_MODULE_AVAILABLE_yup_graphics +#error This binding file requires adding the yup_graphics module in the project +#else +#include +#endif + +#include "yup_YupCore_bindings.h" +#include "yup_YupGraphics_bindings.h" + +#define YUP_PYTHON_INCLUDE_PYBIND11_OPERATORS +#define YUP_PYTHON_INCLUDE_PYBIND11_STL +#include "../utilities/yup_PyBind11Includes.h" + +namespace yup::Bindings +{ + +//============================================================================== + +void registerYupRhiBindings (pybind11::module_& m); + +} // namespace yup::Bindings diff --git a/modules/yup_python/modules/yup_YupMain_module.cpp b/modules/yup_python/modules/yup_YupMain_module.cpp index 65e6749b7..d76426454 100644 --- a/modules/yup_python/modules/yup_YupMain_module.cpp +++ b/modules/yup_python/modules/yup_YupMain_module.cpp @@ -34,6 +34,7 @@ #if YUP_MODULE_AVAILABLE_yup_graphics #include "../bindings/yup_YupGraphics_bindings.h" +#include "../bindings/yup_YupRhi_bindings.h" #endif #if YUP_MODULE_AVAILABLE_yup_gui @@ -44,11 +45,15 @@ #include "../bindings/yup_YupAudioBasics_bindings.h" #endif -/* +#if YUP_MODULE_AVAILABLE_yup_audio_formats +#include "../bindings/yup_YupAudioFormats_bindings.h" +#endif + #if YUP_MODULE_AVAILABLE_yup_audio_devices #include "../bindings/yup_YupAudioDevices_bindings.h" #endif +/* #if YUP_MODULE_AVAILABLE_yup_audio_processors #include "../bindings/yup_YupAudioProcessors_bindings.h" #endif @@ -86,6 +91,7 @@ PYBIND11_MODULE (YUP_PYTHON_MODULE_NAME, m) #if YUP_MODULE_AVAILABLE_yup_graphics yup::Bindings::registerYupGraphicsBindings (m); + yup::Bindings::registerYupRhiBindings (m); #endif #if YUP_MODULE_AVAILABLE_yup_gui @@ -96,11 +102,15 @@ PYBIND11_MODULE (YUP_PYTHON_MODULE_NAME, m) yup::Bindings::registerYupAudioBasicsBindings (m); #endif - /* +#if YUP_MODULE_AVAILABLE_yup_audio_formats + yup::Bindings::registerYupAudioFormatsBindings (m); +#endif + #if YUP_MODULE_AVAILABLE_yup_audio_devices yup::Bindings::registerYupAudioDevicesBindings (m); #endif + /* #if YUP_MODULE_AVAILABLE_yup_audio_processors yup::Bindings::registerYupAudioProcessorsBindings (m); #endif diff --git a/modules/yup_python/yup_python_audio_devices.cpp b/modules/yup_python/yup_python_audio_devices.cpp new file mode 100644 index 000000000..fa2cb86be --- /dev/null +++ b/modules/yup_python/yup_python_audio_devices.cpp @@ -0,0 +1,22 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "bindings/yup_YupAudioDevices_bindings.cpp" diff --git a/modules/yup_python/yup_python_audio_formats.cpp b/modules/yup_python/yup_python_audio_formats.cpp new file mode 100644 index 000000000..cb44a3f22 --- /dev/null +++ b/modules/yup_python/yup_python_audio_formats.cpp @@ -0,0 +1,22 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "bindings/yup_YupAudioFormats_bindings.cpp" diff --git a/modules/yup_python/yup_python_rhi.cpp b/modules/yup_python/yup_python_rhi.cpp new file mode 100644 index 000000000..a22a7e874 --- /dev/null +++ b/modules/yup_python/yup_python_rhi.cpp @@ -0,0 +1,22 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "bindings/yup_YupRhi_bindings.cpp" diff --git a/python/.gitignore b/python/.gitignore index 5dc182871..516b890ce 100644 --- a/python/.gitignore +++ b/python/.gitignore @@ -1,6 +1,8 @@ build/ dist/ +.venv/ *.egg-info/ *.pyc **/__pycache__ **/.pytest_cache/ +uv.lock diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 24c92d6c2..f61205b82 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -50,15 +50,38 @@ yup_standalone_app ( TARGET_APP_NAMESPACE "org.yup" TARGET_WHEEL ON MODULES + yup::yup_animation yup::yup_audio_basics yup::yup_audio_devices + yup::yup_audio_formats + yup::yup_audio_graph + yup::yup_audio_gui + yup::yup_audio_plugin_host yup::yup_audio_processors yup::yup_core yup::yup_data_model + yup::yup_dsp yup::yup_events yup::yup_graphics yup::yup_gui - yup::yup_python) + yup::yup_python + yup::yup_shading + yup::yup_simd + bungee_library + pffft_library + opus_library + flac_library + hmp3_library + dr_libs + libvorbis + libpng + libwebp + libjpeg + libgif + libtiff + glslang + spirv_cross + spirv_tools) set_target_properties (${target_name} PROPERTIES CXX_EXTENSIONS OFF diff --git a/python/demos/animated_component.py b/python/demos/animated_component.py new file mode 100644 index 000000000..1542b6dba --- /dev/null +++ b/python/demos/animated_component.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +YUP Animated Component Demo + +Demonstrates a self-animating component using Timer + repaint(). +Port of popsicle's animated_component.py. +""" + +import yup_init +import yup +import math +import time + + +class AnimatedComponent(yup.Component): + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + + self.startTime = time.perf_counter() + self.timer = yup.Timer(self.onTimer) + self.timer.startTimerHz(60) + + def onTimer(self): + self.repaint() + + def refreshDisplay(self, lastFrameTimeSeconds: float): + pass # Timer handles repainting + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.black) + g.fillAll() + + w = self.getWidth() + h = self.getHeight() + cx = w / 2 + cy = h / 2 + + elapsed = time.perf_counter() - self.startTime + radius = min(w, h) / 4 + + # Bouncing ball + ball_y = cy + math.sin(elapsed * 3.0) * radius * 0.5 + ball_scale = 1.0 + math.sin(elapsed * 5.0) * 0.2 + + r = radius * 0.15 * ball_scale + + gradient = yup.ColorGradient( + yup.Colors.red, + yup.Colors.yellow, + yup.Point[float](cx - r, ball_y - r), + yup.Point[float](cx + r, ball_y + r), + False, + ) + g.setFillColor(gradient) + g.fillEllipse(cx - r, ball_y - r, r * 2, r * 2) + + # Rotating squares + for i in range(6): + angle = elapsed * 2.0 + i * math.pi / 3 + x = cx + math.cos(angle) * radius * 0.6 + y = cy + math.sin(angle) * radius * 0.6 + size = 15 + math.sin(elapsed * 4.0 + i) * 8 + + hue = (i / 6.0 + elapsed * 0.5) % 1.0 + g.setFillColor(yup.Color.fromHSV(hue, 0.8, 1.0, 1.0)) + g.fillRect(x - size / 2, y - size / 2, size, size) + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + AnimatedComponent, + name="Animated Component", + width=600, + height=450, + ) diff --git a/python/demos/audio_device.py b/python/demos/audio_device.py new file mode 100644 index 000000000..579dc9e5d --- /dev/null +++ b/python/demos/audio_device.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +""" +YUP Audio Device Info Demo + +Lists available audio devices and their capabilities. +Port of popsicle's audio_device.py. +""" + +import yup_init +import yup + + +def print_device_info(): + """Print information about available audio devices.""" + manager = yup.AudioDeviceManager() + + print("=" * 60) + print("YUP Audio Device Info") + print("=" * 60) + + # List device types + device_types = manager.getAvailableDeviceTypes() + print(f"\nAvailable device types: {len(device_types)}") + for i, device_type in enumerate(device_types): + print(f"\n--- Device Type {i + 1}: {device_type.getTypeName()} ---") + + # Scan for devices of this type + device_type.scanForDevices() + device_names = device_type.getDeviceNames() + print(f" Devices found: {len(device_names)}") + + for name in device_names: + print(f" - {name}") + + # Try to get current device info + current_device = manager.getCurrentAudioDevice() + if current_device: + print(f"\nCurrent Device:") + print(f" Name: {current_device.getName()}") + print(f" Type: {current_device.getTypeName()}") + print(f" Sample Rate: {current_device.getCurrentSampleRate()} Hz") + print(f" Buffer Size: {current_device.getCurrentBufferSizeSamples()} samples") + print(f" Bit Depth: {current_device.getCurrentBitDepth()}") + print(f" Output Channels: {current_device.getActiveOutputChannels()}") + print(f" Output Latency: {current_device.getOutputLatencyInSamples()} samples") + print(f" Input Latency: {current_device.getInputLatencyInSamples()} samples") + + sample_rates = current_device.getAvailableSampleRates() + print(f" Available Sample Rates: {[f'{r:.0f}' for r in sample_rates]}") + + buffer_sizes = current_device.getAvailableBufferSizes() + print(f" Available Buffer Sizes: {[str(b) for b in buffer_sizes]}") + else: + print("\nNo audio device currently open.") + + print("\n" + "=" * 60) + + +if __name__ == "__main__": + print_device_info() diff --git a/python/demos/audio_player.py b/python/demos/audio_player.py new file mode 100644 index 000000000..36d1bca96 --- /dev/null +++ b/python/demos/audio_player.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +YUP Audio Player Demo + +Demonstrates audio file playback using AudioFormatManager, +AudioFormatReaderSource, and AudioTransportSource. +Port of popsicle's audio_player.py. + +Usage: + python audio_player.py [path/to/audio/file.wav] +""" + +import yup_init +import yup +import sys +import os + + +class AudioPlayer: + """Simple audio file player.""" + + def __init__(self): + self.deviceManager = yup.AudioDeviceManager() + self.formatManager = yup.AudioFormatManager() + self.formatManager.registerDefaultFormats() + + self.transportSource = yup.AudioTransportSource() + self.player = yup.AudioSourcePlayer() + self.player.setSource(self.transportSource) + + self.readerSource = None + self.currentFile = None + + def initialise(self) -> str: + return self.deviceManager.initialise(0, 2, None, True) + + def loadFile(self, filePath: str) -> bool: + """Load an audio file for playback.""" + file = yup.File(filePath) + if not file.existsAsFile(): + print(f"File not found: {filePath}") + return False + + # Stop any current playback + self.transportSource.stop() + self.readerSource = None + + # Create reader and source + reader = self.formatManager.createReaderFor(file) + if reader is None: + print(f"Could not read file: {filePath}") + return False + + print(f"Loaded: {os.path.basename(filePath)}") + print(f" Format: {reader.getFormatName()}") + print(f" Sample Rate: {reader.sampleRate} Hz") + print(f" Channels: {reader.numChannels}") + print(f" Duration: {reader.lengthInSamples / reader.sampleRate:.2f}s") + + self.readerSource = yup.AudioFormatReaderSource(reader, True) + self.transportSource.setSource(self.readerSource) + self.currentFile = filePath + return True + + def play(self): + """Start or resume playback.""" + if self.readerSource is None: + print("No file loaded.") + return + + self.deviceManager.addAudioCallback(self.player) + self.transportSource.start() + print("Playing...") + + def stop(self): + """Stop playback.""" + self.transportSource.stop() + self.deviceManager.removeAudioCallback(self.player) + print("Stopped.") + + def getPosition(self) -> float: + """Get current playback position in seconds.""" + return self.transportSource.getCurrentPosition() + + def getLength(self) -> float: + """Get total length in seconds.""" + return self.transportSource.getLengthInSeconds() + + def isPlaying(self) -> bool: + return self.transportSource.isPlaying() + + +def main(): + player = AudioPlayer() + + result = player.initialise() + if result: + print(f"Error initialising audio: {result}") + return + + # Get file path from arguments or use default + if len(sys.argv) > 1: + filePath = sys.argv[1] + else: + print("Usage: python audio_player.py ") + print("\nProvide a WAV, MP3, or other supported audio file.") + return + + if not player.loadFile(filePath): + return + + player.play() + print("\nControls: [space] play/pause, [q] quit") + + import threading + import time + + running = True + + def input_thread(): + nonlocal running + while running: + try: + cmd = input().strip().lower() + if cmd == "q": + running = False + elif cmd == "" or cmd == " ": + if player.isPlaying(): + player.transportSource.stop() + print("Paused.") + else: + player.transportSource.start() + print("Playing...") + except EOFError: + break + + thread = threading.Thread(target=input_thread, daemon=True) + thread.start() + + try: + while running: + if player.isPlaying(): + pos = player.getPosition() + length = player.getLength() + bar_width = 40 + filled = int(bar_width * pos / length) if length > 0 else 0 + bar = "#" * filled + "-" * (bar_width - filled) + print(f"\r[{bar}] {pos:.1f}s / {length:.1f}s", end="") + time.sleep(0.1) + except KeyboardInterrupt: + pass + finally: + running = False + player.stop() + print("\nDone.") + + +if __name__ == "__main__": + main() diff --git a/python/demos/audio_player_waveform.py b/python/demos/audio_player_waveform.py new file mode 100644 index 000000000..9264f104e --- /dev/null +++ b/python/demos/audio_player_waveform.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +YUP Audio Player with Waveform Demo + +Audio file playback with real-time waveform visualization. +Port of popsicle's audio_player_waveform.py. + +Usage: + python audio_player_waveform.py [path/to/audio/file.wav] +""" + +import yup_init +import yup +import sys +import os +import threading + + +class AudioPlayer: + """Simple audio file player (shared logic with audio_player.py).""" + + def __init__(self): + self.deviceManager = yup.AudioDeviceManager() + self.formatManager = yup.AudioFormatManager() + self.formatManager.registerDefaultFormats() + + self.transportSource = yup.AudioTransportSource() + self.player = yup.AudioSourcePlayer() + self.player.setSource(self.transportSource) + + self.readerSource = None + + def initialise(self) -> str: + return self.deviceManager.initialise(0, 2, None, True) + + def loadFile(self, filePath: str) -> bool: + file = yup.File(filePath) + if not file.existsAsFile(): + return False + + self.transportSource.stop() + self.readerSource = None + + reader = self.formatManager.createReaderFor(file) + if reader is None: + return False + + self.readerSource = yup.AudioFormatReaderSource(reader, True) + self.transportSource.setSource(self.readerSource) + return True + + def play(self): + if self.readerSource: + self.deviceManager.addAudioCallback(self.player) + self.transportSource.start() + + def stop(self): + self.transportSource.stop() + self.deviceManager.removeAudioCallback(self.player) + + def isPlaying(self) -> bool: + return self.transportSource.isPlaying() + + def getPosition(self) -> float: + return self.transportSource.getCurrentPosition() + + def getLength(self) -> float: + return self.transportSource.getLengthInSeconds() + + def getReader(self): + """Get the underlying format reader for waveform analysis.""" + if self.readerSource: + return self.readerSource.getAudioFormatReader() + return None + + +class WaveformComponent(yup.Component): + """Component that displays an audio waveform and playback position.""" + + def __init__(self, player: AudioPlayer): + yup.Component.__init__(self) + self.player = player + self.setOpaque(True) + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.black) + g.fillAll() + + reader = self.player.getReader() + if reader is None: + g.setFillColor(yup.Colors.white) + font = yup.Font(yup.FontOptions(16.0)) + g.fillFittedText( + "No audio file loaded", + font, + yup.Rectangle[float](0, 0, self.getWidth(), self.getHeight()), + yup.Justification.centred, + ) + return + + w = self.getWidth() + h = self.getHeight() + + # Draw waveform + numSamples = reader.lengthInSamples + numChannels = reader.numChannels + sampleRate = reader.sampleRate + + # Downsample to fit the width + downsample = max(1, numSamples // w) + g.setStrokeColor(yup.Colors.green) + g.setStrokeWidth(1) + + mid_y = h / 2 + scale = h / 2 + + # Read samples and draw waveform + buffer = yup.AudioBuffer[float](numChannels, downsample) + x = 0.0 + for i in range(0, numSamples, downsample): + samples_to_read = min(downsample, numSamples - i) + reader.read(buffer, 0, samples_to_read, i, True, True) + + # Find peak in this chunk + peak = 0.0 + for s in range(samples_to_read): + peak = max(peak, abs(buffer.getSample(0, s))) + + y = peak * scale + g.strokeLine(x, mid_y - y, x, mid_y + y) + x += 1.0 + + # Draw playback position + pos = self.player.getPosition() + length = self.player.getLength() + if length > 0: + pos_x = (pos / length) * w + g.setStrokeColor(yup.Colors.red) + g.setStrokeWidth(2) + g.strokeLine(pos_x, 0, pos_x, h) + + # Draw time info + g.setFillColor(yup.Colors.white) + font = yup.Font(yup.FontOptions(14.0)) + time_str = f"{pos:.1f}s / {length:.1f}s" + g.fillFittedText( + time_str, + font, + yup.Rectangle[float](10, h - 30, 200, 20), + yup.Justification.left, + ) + + def refreshDisplay(self, lastFrameTimeSeconds: float): + self.repaint() + + +def main(): + if len(sys.argv) < 2: + print("Usage: python audio_player_waveform.py ") + return + + filePath = sys.argv[1] + player = AudioPlayer() + result = player.initialise() + if result: + print(f"Error: {result}") + return + + if not player.loadFile(filePath): + print(f"Could not load: {filePath}") + return + + player.play() + + # Create and show the waveform component + class PlayerApp(yup.YUPApplication): + def getApplicationName(self): + return "Audio Player" + + def getApplicationVersion(self): + return "1.0" + + def initialise(self, cmdLine): + class Win(yup.DocumentWindow): + def __init__(self): + super().__init__() + self.setTitle("Audio Player") + self.comp = WaveformComponent(player) + self.addAndMakeVisible(self.comp) + + def resized(self): + self.comp.setBounds(self.getLocalBounds()) + + def userTriedToCloseWindow(self): + yup.YUPApplication.getInstance().systemRequestedQuit() + + self.win = Win() + self.win.setVisible(True) + self.win.centreWithSize(yup.Size[int](800, 300)) + + def shutdown(self): + player.stop() + + def systemRequestedQuit(self): + self.quit() + + yup.START_YUP_APPLICATION(PlayerApp) + + +if __name__ == "__main__": + main() diff --git a/python/demos/drawables.py b/python/demos/drawables.py new file mode 100644 index 000000000..e094ccef5 --- /dev/null +++ b/python/demos/drawables.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +YUP Drawables Demo + +Demonstrates path drawing, color gradients, and stroke styles. +Port of popsicle's drawables.py. +""" + +import yup_init +import yup +import math + + +class DrawablesComponent(yup.Component): + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.black) + g.fillAll() + + bounds = yup.Rectangle[float](0, 0, self.getWidth(), self.getHeight()) + + # --- Filled rectangle + g.setFillColor(yup.Colors.darkblue) + g.fillRect(20, 20, 150, 80) + + # --- Stroked rectangle + g.setStrokeColor(yup.Colors.lightgreen) + g.setStrokeWidth(3) + g.strokeRect(200, 20, 150, 80) + + # --- Filled ellipse + g.setFillColor(yup.Colors.orange) + g.fillEllipse(20, 130, 150, 80) + + # --- Color gradient + gradient = yup.ColorGradient( + yup.Colors.red, + yup.Colors.blue, + yup.Point[float](400, 20), + yup.Point[float](550, 100), + False, + ) + g.setFillColor(gradient) + g.fillRoundedRect(380, 20, 170, 80, 10) + + # --- Custom path with stroke + path = yup.Path() + path.startNewSubPath(20, 250) + path.lineTo(80, 220) + path.lineTo(140, 250) + path.lineTo(170, 300) + path.lineTo(110, 310) + path.lineTo(50, 310) + path.lineTo(20, 300) + path.closeSubPath() + + g.setStrokeColor(yup.Colors.yellow) + g.setStrokeWidth(2) + g.strokePath(path) + + g.setFillColor(yup.Colors.yellow.withAlpha(0.3)) + g.fillPath(path) + + # --- Lines + g.setStrokeColor(yup.Colors.white) + g.setStrokeWidth(1) + for i in range(8): + x = 200 + i * 45 + g.strokeLine(x, 250, x + 30, 320) + + # --- Dashed lines (using custom path) + g.setStrokeColor(yup.Colors.cyan) + g.setStrokeWidth(2) + g.strokeLine(200, 350, 550, 350) + + # --- Text + g.setFillColor(yup.Colors.white) + g.drawText("YUP Drawables Demo", bounds, yup.Justification.centredBottom) + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + DrawablesComponent, + name="Drawables", + width=600, + height=450, + ) diff --git a/python/demos/emojis_component.py b/python/demos/emojis_component.py new file mode 100644 index 000000000..a70ff5164 --- /dev/null +++ b/python/demos/emojis_component.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +""" +YUP Emoji Component Demo + +Demonstrates font rendering with emoji characters. +Port of popsicle's emojis_component.py. + +NOTE: This demo requires the NotoColorEmoji.ttf font file. +Download it from: https://github.com/googlefonts/noto-emoji +Place it in the same directory as this script. +""" + +import yup_init +import yup +import os + +# Emoji characters to render +EMOJIS = ["😀", "🎉", "🚀", "💻", "🎵", "🌟", "🔥", "❤️", "🎨", "🐍"] + + +class EmojiComponent(yup.Component): + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.darkgrey) + g.fillAll() + + w = self.getWidth() + h = self.getHeight() + + # Try to use emoji font, fall back to default + emoji_font_path = os.path.join( + os.path.dirname(__file__), "NotoColorEmoji.ttf" + ) + emoji_size = min(w, h) // 5 + + for i, emoji in enumerate(EMOJIS): + col = i % 5 + row = i // 5 + x = col * w // 5 + w // 10 + y = row * h // 2 + h // 4 + + g.setFillColor(yup.Colors.white) + g.drawText( + emoji, + yup.Rectangle[float](x - emoji_size // 2, y - emoji_size // 2, + emoji_size, emoji_size), + yup.Justification.centred, + ) + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + EmojiComponent, + name="Emoji Demo", + width=600, + height=400, + ) diff --git a/python/demos/emojis_font_component.py b/python/demos/emojis_font_component.py new file mode 100644 index 000000000..3229662c0 --- /dev/null +++ b/python/demos/emojis_font_component.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +""" +YUP Emoji Font Component Demo + +Demonstrates loading a custom font and rendering text with it. +Port of popsicle's emojis_font_component.py. + +NOTE: This demo requires the NotoColorEmoji.ttf font file. +Download it from: https://github.com/googlefonts/noto-emoji +Place it in the same directory as this script. +""" + +import yup_init +import yup + + +class EmojiFontComponent(yup.Component): + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.black) + g.fillAll() + + w = self.getWidth() + + g.setFillColor(yup.Colors.white) + g.drawText( + "Hello YUP! 🚀✨", + yup.Rectangle[float](0, 20, w, 60), + yup.Justification.centred, + ) + + g.setFillColor(yup.Colors.lightblue) + g.drawText( + "Python + C++ = ❤️", + yup.Rectangle[float](0, 100, w, 60), + yup.Justification.centred, + ) + + g.setFillColor(yup.Colors.orange) + g.drawText( + "🎵 Audio 🎨 Graphics 🎮 UI", + yup.Rectangle[float](0, 180, w, 60), + yup.Justification.centred, + ) + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + EmojiFontComponent, + name="Emoji Font Demo", + width=600, + height=300, + ) diff --git a/python/demos/gpu_canvas.py b/python/demos/gpu_canvas.py new file mode 100644 index 000000000..5a6601ca4 --- /dev/null +++ b/python/demos/gpu_canvas.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +YUP GPU Canvas Demo + +Follows the OffscreenRenderDemo pattern: renders 2D content to an +offscreen GpuCanvas via beginDraw()/commit(), then composites the +result onto the screen with drawImage(). +""" + +import yup_init +import yup +import math +import time + + +class CanvasWindow(yup.DocumentWindow): + def __init__(self): + super().__init__() + self.setTitle("GPU Canvas Demo") + self.component = CanvasComponent() + self.addAndMakeVisible(self.component) + + def resized(self): + self.component.setBounds(self.getLocalBounds()) + + def userTriedToCloseWindow(self): + yup.YUPApplication.getInstance().systemRequestedQuit() + + +class CanvasComponent(yup.Component): + CANVAS_SIZE = 512 + + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + self._ctx = None + self._canvas = None + self._image = None + self._startTime = time.perf_counter() + self.timer = yup.Timer(self.onTimer) + self.timer.startTimerHz(30) + + def onTimer(self): + self.repaint() + + def refreshDisplay(self, lastFrameTimeSeconds: float): + pass + + # ------------------------------------------------------------------ + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.darkgrey) + g.fillAll() + + if self._ctx is None: + self._ctx = g.getGraphicsContext() + + if self._ctx is None or not self._ctx.isGpuAvailable(): + return + + if self._canvas is None: + self._canvas = yup.GpuCanvas.create(self._ctx, self.CANVAS_SIZE, self.CANVAS_SIZE) + if self._canvas is None: + return + + # ---- 2D draw to offscreen canvas ---- + g2 = self._canvas.beginDraw() + try: + w = self._canvas.getWidth() + h = self._canvas.getHeight() + t = time.perf_counter() - self._startTime + + g2.setFillColor(yup.Colors.black) + g2.fillAll() + + for i in range(8): + angle = t * 2.0 + i * math.pi / 4 + cx = w / 2 + math.cos(angle) * 150 + cy = h / 2 + math.sin(angle) * 150 + r = 20 + math.sin(t * 3 + i) * 10 + hue = (i / 8.0 + t * 0.2) % 1.0 + g2.setFillColor(yup.Color.fromHSV(hue, 0.8, 1.0, 0.8)) + g2.fillEllipse(cx - r, cy - r, r * 2, r * 2) + + g2.setStrokeColor(yup.Colors.white) + g2.setStrokeWidth(3) + g2.strokeRect(yup.Rectangle[float](10, 10, w - 20, h - 20)) + + font = yup.Font(yup.FontOptions(28.0)) + g2.setFillColor(yup.Colors.white) + g2.fillFittedText( + "GPU Canvas", font, + yup.Rectangle[float](0, h - 50, w, 40), + yup.Justification.centred, + ) + finally: + self._canvas.commit() + + self._image = self._canvas.asImage() + + # ---- Composite to screen ---- + if self._image.isValid(): + iw = float(self._image.getWidth()) + ih = float(self._image.getHeight()) + w = float(self.getWidth()) + h = float(self.getHeight()) + scale = min(w / iw, h / ih) * 0.9 + dw = iw * scale + dh = ih * scale + g.drawImage( + self._image, + yup.Rectangle[float]((w - dw) / 2, (h - dh) / 2, dw, dh), + ) + + apiNames = {0: "Headless", 1: "OpenGL", 2: "OpenGL ES", + 3: "Direct3D", 4: "Metal", 5: "WebGPU"} + api = apiNames.get(self._ctx.getApi(), "?") + font = yup.Font(yup.FontOptions(14.0)) + g.setFillColor(yup.Colors.white.withAlpha(0.7)) + g.fillFittedText( + f"GPU: {api}", font, + yup.Rectangle[float](10, h - 25, 200, 20), + yup.Justification.left, + ) + + +class Application(yup.YUPApplication): + def getApplicationName(self): + return "GPU Canvas Demo" + + def getApplicationVersion(self): + return "1.0" + + def initialise(self, commandLineParameters: str): + self.window = CanvasWindow() + + def show(): + yup.Process.makeForegroundProcess() + self.window.setVisible(True) + self.window.centreWithSize(yup.Size[int](640, 640)) + + yup.MessageManager.callAsync(show) + + def shutdown(self): + del self.window + + def systemRequestedQuit(self): + self.quit() + + +if __name__ == "__main__": + yup.START_YUP_APPLICATION(Application) diff --git a/python/demos/gpu_effects.py b/python/demos/gpu_effects.py new file mode 100644 index 000000000..194536c4e --- /dev/null +++ b/python/demos/gpu_effects.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +YUP GPU Effects Demo + +Follows the SpinningCubeDemo multi-pass pattern: + Pass 1 — draw animated shapes to an offscreen GpuCanvas (2D path) + Pass 2 — fullscreen pass to GpuTarget via GLSL 450 pipeline +Both passes share a single GpuFrame; composite final result to screen +with drawTexture(). +""" + +import yup_init +import yup +import math +import time + + +# --------------------------------------------------------------------------- +# GLSL 450 fullscreen triangle + simple color-transform fragment +# --------------------------------------------------------------------------- + +VERT_GLSL = """#version 450 +void main() { + float x = float((gl_VertexIndex & 1u) << 2u) - 1.0; + float y = float((gl_VertexIndex & 2u) << 1u) - 1.0; + gl_Position = vec4(x, y, 0.0, 1.0); +} +""" + +FRAG_GLSL = """#version 450 +layout(set=0, binding=0) uniform texture2D u_tex; +layout(set=0, binding=1) uniform sampler u_samp; +layout(location=0) out vec4 fragColor; + +void main() { + vec2 uv = gl_FragCoord.xy / vec2(512.0, 512.0); + + // Sample the pass-1 texture + vec4 col = texture(sampler2D(u_tex, u_samp), uv); + + // Apply a vignette effect + vec2 v = uv - 0.5; + float vignette = 1.0 - dot(v, v) * 1.2; + + // Slight warm tint + col.rgb *= vec3(1.05, 0.95, 0.9); + + fragColor = col * vignette; +} +""" + + +# --------------------------------------------------------------------------- +class EffectsWindow(yup.DocumentWindow): + def __init__(self): + super().__init__() + self.setTitle("GPU Effects Demo") + self.component = EffectsComponent() + self.addAndMakeVisible(self.component) + + def resized(self): + self.component.setBounds(self.getLocalBounds()) + + def userTriedToCloseWindow(self): + yup.YUPApplication.getInstance().systemRequestedQuit() + + +class EffectsComponent(yup.Component): + SIZE = 512 + + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + self._ctx = None + self._canvas2D = None # pass 1: 2D drawing + self._target = None # pass 2: render target + self._pipeline = None + self._gpuTexture = None + self._initOk = False + self._didInit = False + self._startTime = time.perf_counter() + self.timer = yup.Timer(self.onTimer) + self.timer.startTimerHz(30) + + def onTimer(self): + self.repaint() + + def refreshDisplay(self, lastFrameTimeSeconds: float): + pass + + # ------------------------------------------------------------------ + def _ensureInit(self): + if self._didInit or self._ctx is None: + return + self._didInit = True + + self._canvas2D = yup.GpuCanvas.create(self._ctx, self.SIZE, self.SIZE) + self._target = yup.GpuTarget.create(self._ctx, self.SIZE, self.SIZE) + + if self._canvas2D is None or self._target is None: + return + + result = yup.GpuPipeline.compileFromGlsl( + self._ctx, VERT_GLSL, FRAG_GLSL, yup.GpuPipelineOptions(), + ) + if result: + self._pipeline = result.getValue() + self._initOk = True + + # ------------------------------------------------------------------ + def _render(self): + if not self._initOk: + return + + # --- Pass 1: 2D drawing to canvas --- + g2 = self._canvas2D.beginDraw() + try: + cw = float(self._canvas2D.getWidth()) + ch = float(self._canvas2D.getHeight()) + t = time.perf_counter() - self._startTime + + g2.setFillColor(yup.Colors.darkblue) + g2.fillAll() + + for i in range(6): + r = 40.0 + float(i) * 35.0 + math.sin(t * 2.0 + i) * 10.0 + hue = (float(i) / 6.0 + t * 0.15) % 1.0 + g2.setStrokeColor(yup.Color.fromHSV(hue, 0.8, 1.0, 0.7)) + g2.setStrokeWidth(4.0) + g2.strokeEllipse( + yup.Rectangle[float](cw / 2.0 - r, ch / 2.0 - r, r * 2.0, r * 2.0) + ) + + font = yup.Font(yup.FontOptions(24.0)) + g2.setFillColor(yup.Colors.white) + g2.fillFittedText( + "Multi-Pass GPU", font, + yup.Rectangle[float](0.0, ch - 50.0, cw, 40.0), + yup.Justification.centred, + ) + finally: + self._canvas2D.commit() + + inputTex = self._canvas2D.asTexture() + + # --- Pass 2: fullscreen pipeline with vignette effect --- + # Both passes share one GpuFrame — GPU serialises them. + frame = yup.GpuFrame.begin(self._ctx) + + ropts = yup.GpuRenderOptions(True, yup.Colors.transparentBlack) + rp = self._target.beginRenderPass(frame, ropts) + + rp.setPipeline(self._pipeline) + rp.setTexture(0, 0, inputTex) + rp.draw(3) + rp.finish() + + frame.submit() + self._gpuTexture = self._target.asTexture() + + # ------------------------------------------------------------------ + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.darkgrey) + g.fillAll() + + if self._ctx is None: + self._ctx = g.getGraphicsContext() + + if self._ctx is None or not self._ctx.isGpuAvailable(): + return + + self._ensureInit() + + if not self._initOk: + font = yup.Font(yup.FontOptions(18.0)) + g.setFillColor(yup.Colors.orange) + g.fillFittedText( + "Pipeline compilation failed", font, + yup.Rectangle[float](0, self.getHeight() / 2 - 20, + self.getWidth(), 40), + yup.Justification.centred, + ) + return + + self._render() + + tex = self._gpuTexture + if tex and tex.isValid(): + tw = float(tex.getWidth()) + th = float(tex.getHeight()) + w = float(self.getWidth()) + h = float(self.getHeight()) + scale = min(w / tw, h / th) * 0.9 + dw = tw * scale + dh = th * scale + g.drawTexture( + tex, + yup.Rectangle[float]((w - dw) / 2, (h - dh) / 2, dw, dh), + ) + + apiNames = {0: "Headless", 1: "OpenGL", 2: "OpenGL ES", + 3: "Direct3D", 4: "Metal", 5: "WebGPU"} + api = apiNames.get(self._ctx.getApi(), "?") + font = yup.Font(yup.FontOptions(14.0)) + g.setFillColor(yup.Colors.white.withAlpha(0.7)) + g.fillFittedText( + f"GPU: {api} | GLSL 450 | Canvas → Vignette → Screen", font, + yup.Rectangle[float](10, h - 25, 420, 20), + yup.Justification.left, + ) + + +class Application(yup.YUPApplication): + def getApplicationName(self): + return "GPU Effects Demo" + + def getApplicationVersion(self): + return "1.0" + + def initialise(self, commandLineParameters: str): + self.window = EffectsWindow() + + def show(): + yup.Process.makeForegroundProcess() + self.window.setVisible(True) + self.window.centreWithSize(yup.Size[int](640, 640)) + + yup.MessageManager.callAsync(show) + + def shutdown(self): + del self.window + + def systemRequestedQuit(self): + self.quit() + + +if __name__ == "__main__": + yup.START_YUP_APPLICATION(Application) diff --git a/python/demos/gpu_triangle.py b/python/demos/gpu_triangle.py new file mode 100644 index 000000000..2087da4ba --- /dev/null +++ b/python/demos/gpu_triangle.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +YUP GPU Triangle Demo — "Hello Triangle" + +Follows the SpinningCubeDemo renderCube pattern: + - Compile GLSL 450 shaders via GpuPipeline::compileFromGlsl + - Render to a GpuTarget via beginRenderPass() + - Composite to screen with drawTexture() + +Fullscreen-triangle vertex shader (no vertex buffer), fragment shader +fills a colored triangle using gl_FragCoord. +""" + +import yup_init +import yup + + +# --------------------------------------------------------------------------- +# GLSL 450 shaders — fullscreen triangle, no vertex buffer +# --------------------------------------------------------------------------- + +VERT_GLSL = """#version 450 +void main() { + // Map gl_VertexIndex (0,1,2) to a fullscreen triangle + float x = float((gl_VertexIndex & 1u) << 2u) - 1.0; + float y = float((gl_VertexIndex & 2u) << 1u) - 1.0; + gl_Position = vec4(x, y, 0.0, 1.0); +} +""" + +FRAG_GLSL = """#version 450 +layout(location=0) out vec4 fragColor; + +void main() { + // Normalised fragment position (0..1) + vec2 uv = gl_FragCoord.xy / vec2(512.0, 512.0); + + // Draw a colored triangle: red at top-left, green at top-right, blue at bottom-center + vec3 red = vec3(1.0, 0.0, 0.0); // top-left + vec3 green = vec3(0.0, 1.0, 0.0); // top-right + vec3 blue = vec3(0.0, 0.0, 1.0); // bottom-center + + // Barycentric-like interpolation based on fragment position + float w0 = 1.0 - uv.x - uv.y * 0.5; // red weight + float w1 = uv.x - uv.y * 0.5; // green weight + float w2 = uv.y; // blue weight + + // Only draw inside the triangle region + if (w0 < 0.0 || w1 < 0.0 || w2 < 0.0) + discard; + + float sum = w0 + w1 + w2; + vec3 color = (red * w0 + green * w1 + blue * w2) / sum; + + // Add a subtle black-to-transparent border + float border = 1.0 - smoothstep(0.0, 0.03, min(min(w0, w1), w2)); + fragColor = vec4(mix(color, vec3(0.0), border), 1.0); +} +""" + + +# --------------------------------------------------------------------------- +class TriangleWindow(yup.DocumentWindow): + def __init__(self): + super().__init__() + self.setTitle("GPU Hello Triangle") + self.component = TriangleComponent() + self.addAndMakeVisible(self.component) + + def resized(self): + self.component.setBounds(self.getLocalBounds()) + + def userTriedToCloseWindow(self): + yup.YUPApplication.getInstance().systemRequestedQuit() + + +class TriangleComponent(yup.Component): + TARGET_SIZE = 512 + + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + self._ctx = None + self._pipeline = None + self._target = None + self._gpuTexture = None + self._initOk = False + self._didInit = False + self.timer = yup.Timer(self.onTimer) + self.timer.startTimerHz(30) + + def onTimer(self): + self.repaint() + + def refreshDisplay(self, lastFrameTimeSeconds: float): + pass + + # ------------------------------------------------------------------ + def _ensureInit(self): + if self._didInit or self._ctx is None: + return + self._didInit = True + + result = yup.GpuPipeline.compileFromGlsl( + self._ctx, VERT_GLSL, FRAG_GLSL, yup.GpuPipelineOptions(), + ) + if result: + self._pipeline = result.getValue() + self._target = yup.GpuTarget.create(self._ctx, self.TARGET_SIZE, self.TARGET_SIZE) + self._initOk = self._target is not None + + # ------------------------------------------------------------------ + def _render(self): + if not self._initOk: + return + + # Single frame, single render pass, 3-vertex fullscreen triangle + frame = yup.GpuFrame.begin(self._ctx) + ropts = yup.GpuRenderOptions(True, yup.Colors.black) + rp = self._target.beginRenderPass(frame, ropts) + + rp.setPipeline(self._pipeline) + rp.draw(3) + rp.finish() + + frame.submit() + self._gpuTexture = self._target.asTexture() + + # ------------------------------------------------------------------ + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.darkgrey) + g.fillAll() + + if self._ctx is None: + self._ctx = g.getGraphicsContext() + + if self._ctx is None or not self._ctx.isGpuAvailable(): + return + + self._ensureInit() + + if not self._initOk: + font = yup.Font(yup.FontOptions(18.0)) + g.setFillColor(yup.Colors.orange) + g.fillFittedText( + "Pipeline compilation failed", font, + yup.Rectangle[float](0, self.getHeight() / 2 - 20, + self.getWidth(), 40), + yup.Justification.centred, + ) + return + + self._render() + + tex = self._gpuTexture + if tex and tex.isValid(): + tw = float(tex.getWidth()) + th = float(tex.getHeight()) + w = float(self.getWidth()) + h = float(self.getHeight()) + scale = min(w / tw, h / th) * 0.9 + dw = tw * scale + dh = th * scale + g.drawTexture( + tex, + yup.Rectangle[float]((w - dw) / 2, (h - dh) / 2, dw, dh), + ) + + apiNames = {0: "Headless", 1: "OpenGL", 2: "OpenGL ES", + 3: "Direct3D", 4: "Metal", 5: "WebGPU"} + api = apiNames.get(self._ctx.getApi(), "?") + font = yup.Font(yup.FontOptions(14.0)) + g.setFillColor(yup.Colors.white.withAlpha(0.7)) + g.fillFittedText( + f"GPU: {api} | GLSL 450 | Hello Triangle", font, + yup.Rectangle[float](10, h - 25, 350, 20), + yup.Justification.left, + ) + + +class Application(yup.YUPApplication): + def getApplicationName(self): + return "GPU Hello Triangle" + + def getApplicationVersion(self): + return "1.0" + + def initialise(self, commandLineParameters: str): + self.window = TriangleWindow() + + def show(): + yup.Process.makeForegroundProcess() + self.window.setVisible(True) + self.window.centreWithSize(yup.Size[int](600, 600)) + + yup.MessageManager.callAsync(show) + + def shutdown(self): + del self.window + + def systemRequestedQuit(self): + self.quit() + + +if __name__ == "__main__": + yup.START_YUP_APPLICATION(Application) diff --git a/python/demos/hotreload_component.py b/python/demos/hotreload_component.py new file mode 100644 index 000000000..96ba7db24 --- /dev/null +++ b/python/demos/hotreload_component.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +""" +YUP Hot Reload Demo - Component + +The dynamically reloaded component used by hotreload_main.py. +Edit this file while hotreload_main.py is running to see live updates. + +Port of popsicle's hotreload_component.py. +""" + +import yup +import math +import time + + +class DynamicComponent(yup.Component): + """A component that can be hot-reloaded. Edit and save to see changes.""" + + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + self.startTime = time.perf_counter() + + def refreshDisplay(self, lastFrameTimeSeconds: float): + self.repaint() + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.black) + g.fillAll() + + w = self.getWidth() + h = self.getHeight() + elapsed = time.perf_counter() - self.startTime + + # Draw a moving pattern + cx = w / 2 + cy = h / 2 + radius = min(w, h) / 3 + + # Rotating circles + for i in range(12): + angle = elapsed + i * math.pi / 6 + x = cx + math.cos(angle) * radius + y = cy + math.sin(angle) * radius * 0.6 + + r = 10 + math.sin(elapsed * 3 + i) * 5 + hue = (i / 12.0 + elapsed * 0.2) % 1.0 + + g.setFillColor(yup.Color.fromHSV(hue, 0.8, 1.0, 1.0)) + g.fillEllipse(x - r, y - r, r * 2, r * 2) + + # Title + g.setFillColor(yup.Colors.white) + g.drawText( + "Hot Reload Component - Edit me! 🔄", + yup.Rectangle[float](0, 20, w, 40), + yup.Justification.centred, + ) + + # Timestamp + t = time.strftime("%H:%M:%S") + g.drawText( + f"Last loaded: {t}", + yup.Rectangle[float](0, h - 40, w, 30), + yup.Justification.centred, + ) diff --git a/python/demos/hotreload_main.py b/python/demos/hotreload_main.py new file mode 100644 index 000000000..212c3c105 --- /dev/null +++ b/python/demos/hotreload_main.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +YUP Hot Reload Demo - Main + +Demonstrates a hot-reload pattern where the component is reloaded +from disk when the file changes. Run this script, then edit +hotreload_component.py while the window is open to see changes. + +Port of popsicle's hotreload_main.py. +""" + +import yup_init +import yup +import importlib +import os +import sys +import time + +# Make sure the demos directory is in the path +sys.path.insert(0, os.path.dirname(__file__)) + + +class HotReloadWindow(yup.DocumentWindow): + def __init__(self): + super().__init__() + self.setTitle("Hot Reload Demo") + self.component = None + self.last_mtime = 0 + self.reload_component() + self.timer = yup.Timer(self.checkReload) + self.timer.startTimer(500) + + def reload_component(self): + try: + import hotreload_component + + importlib.reload(hotreload_component) + + if self.component: + self.removeChildComponent(self.component) + del self.component + + self.component = hotreload_component.DynamicComponent() + self.addAndMakeVisible(self.component) + self.component.setBounds(self.getLocalBounds()) + + print(f"[HotReload] Component reloaded successfully") + except Exception as e: + print(f"[HotReload] Error reloading component: {e}") + import traceback + + traceback.print_exc() + + def checkReload(self): + try: + comp_path = os.path.join( + os.path.dirname(__file__), "hotreload_component.py" + ) + current_mtime = os.path.getmtime(comp_path) + if current_mtime > self.last_mtime: + self.last_mtime = current_mtime + print(f"[HotReload] File changed, reloading...") + self.reload_component() + except Exception: + pass + + def resized(self): + if self.component: + self.component.setBounds(self.getLocalBounds()) + + def userTriedToCloseWindow(self): + yup.YUPApplication.getInstance().systemRequestedQuit() + + +class Application(yup.YUPApplication): + window = None + + def getApplicationName(self): + return "Hot Reload Demo" + + def getApplicationVersion(self): + return "1.0" + + def initialise(self, commandLineParameters: str): + self.window = HotReloadWindow() + + def showWindow(): + yup.Process.makeForegroundProcess() + self.window.setVisible(True) + self.window.centreWithSize(yup.Size[int](600, 450)) + + yup.MessageManager.callAsync(showWindow) + + def shutdown(self): + if self.window: + del self.window + + def systemRequestedQuit(self): + self.quit() + + +if __name__ == "__main__": + yup.START_YUP_APPLICATION(Application) diff --git a/python/demos/layout_flexgrid.py b/python/demos/layout_flexgrid.py new file mode 100644 index 000000000..8a9a2b336 --- /dev/null +++ b/python/demos/layout_flexgrid.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +YUP FlexBox & Grid Layout Demo + +Demonstrates CSS-style FlexBox and Grid layout engines for +arranging components in a window. +Port of popsicle's layout_flexgrid.py. +""" + +import yup_init +import yup + + +class LayoutFlexGridComponent(yup.Component): + """Demonstrates FlexBox and Grid layouts.""" + + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + + # Child components + self.header = yup.Label("Header") + self.header.setOpaque(True) + self.header.setColor(yup.Label.backgroundColorId, yup.Colors.darkblue) + self.header.setText("FlexBox & Grid Layout Demo", + yup.NotificationType.dontSendNotification) + self.addAndMakeVisible(self.header) + + self.sidebarLeft = yup.Label("Sidebar Left") + self.sidebarLeft.setOpaque(True) + self.sidebarLeft.setColor(yup.Label.backgroundColorId, yup.Colors.darkgreen) + self.sidebarLeft.setText("Sidebar\nLeft", + yup.NotificationType.dontSendNotification) + self.addAndMakeVisible(self.sidebarLeft) + + self.sidebarRight = yup.Label("Sidebar Right") + self.sidebarRight.setOpaque(True) + self.sidebarRight.setColor(yup.Label.backgroundColorId, yup.Colors.darkgreen) + self.sidebarRight.setText("Sidebar\nRight", + yup.NotificationType.dontSendNotification) + self.addAndMakeVisible(self.sidebarRight) + + self.content = yup.Label("Content") + self.content.setOpaque(True) + self.content.setColor(yup.Label.backgroundColorId, yup.Colors.darkgrey) + self.content.setText("Main Content Area", + yup.NotificationType.dontSendNotification) + self.addAndMakeVisible(self.content) + + self.footer = yup.Label("Footer") + self.footer.setOpaque(True) + self.footer.setColor(yup.Label.backgroundColorId, yup.Colors.darkred) + self.footer.setText("Footer - Status Bar", + yup.NotificationType.dontSendNotification) + self.addAndMakeVisible(self.footer) + + def resized(self): + bounds = self.getLocalBounds() + + # Use FlexBox for the main layout (column direction) + flex = yup.FlexBox( + yup.FlexDirection.column, + yup.FlexWrap.noWrap, + yup.FlexAlignItems.stretch, + yup.FlexJustifyContent.flexStart, + yup.FlexAlignContent.stretch, + ) + flex.gap = 4 + + flex.items.add( + yup.FlexItem(self.header, 0, 40) + .withMinHeight(30) + ) + flex.items.add( + yup.FlexItem(self.content, 0, 0) + .withFlex(1.0) + .withMinHeight(100) + ) + + # Body area: use a nested FlexBox for sidebar-content-sidebar + bodyFlex = yup.FlexBox( + yup.FlexDirection.row, + yup.FlexWrap.noWrap, + yup.FlexAlignItems.stretch, + yup.FlexJustifyContent.flexStart, + yup.FlexAlignContent.stretch, + ) + bodyFlex.gap = 4 + bodyFlex.items.add( + yup.FlexItem(self.sidebarLeft, 120, 0) + .withMinWidth(80) + ) + bodyFlex.items.add(yup.FlexItem(self.content, 0, 0).withFlex(1.0)) + bodyFlex.items.add( + yup.FlexItem(self.sidebarRight, 120, 0) + .withMinWidth(80) + ) + + flex.items.add( + yup.FlexItem(self.footer, 0, 30) + .withMinHeight(25) + ) + + flex.performLayout(bounds) + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.black) + g.fillAll() + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + LayoutFlexGridComponent, + name="FlexBox & Grid Layout", + width=800, + height=500, + ) diff --git a/python/demos/layout_rectangles.py b/python/demos/layout_rectangles.py new file mode 100644 index 000000000..a220ed5a5 --- /dev/null +++ b/python/demos/layout_rectangles.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +YUP Layout Rectangles Demo + +Demonstrates Rectangle positioning math for layout calculations. +Port of popsicle's layout_rectangles.py. +""" + +import yup_init +import yup + + +class LayoutRectanglesComponent(yup.Component): + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.black) + g.fillAll() + + w = self.getWidth() + h = self.getHeight() + + # Main area (centered) + main_area = yup.Rectangle[int](40, 40, w - 80, h - 80) + g.setStrokeColor(yup.Colors.darkgrey) + g.setStrokeWidth(1) + g.strokeRect(main_area.to()) + + # Sidebar (left 25%) + sidebar = main_area.withWidth(main_area.getWidth() // 4) + g.setFillColor(yup.Colors.darkblue.withAlpha(0.3)) + g.fillRect(sidebar.to()) + + # Content area (right 75%) + content = main_area.withLeft(sidebar.getRight() + 10) + g.setFillColor(yup.Colors.darkgreen.withAlpha(0.3)) + g.fillRect(content.to()) + + # Header within content + header = content.withHeight(40) + g.setFillColor(yup.Colors.darkred.withAlpha(0.3)) + g.fillRect(header.to()) + + # Body below header + body = content.withTop(header.getBottom() + 10) + g.setFillColor(yup.Colors.grey.withAlpha(0.3)) + g.fillRect(body.to()) + + # Labels + g.setFillColor(yup.Colors.white) + g.drawText( + "Sidebar", + sidebar.to(), + yup.Justification.centred, + ) + g.drawText( + "Header", + header.to(), + yup.Justification.centred, + ) + g.drawText( + "Content Body", + body.to(), + yup.Justification.centred, + ) + + # Dimensions info + g.setFillColor(yup.Colors.lightgrey) + info = f"Window: {w}x{h} | Main: {main_area.getWidth()}x{main_area.getHeight()}" + g.drawText( + info, + yup.Rectangle[float](10, h - 30, w - 20, 20), + yup.Justification.left, + ) + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + LayoutRectanglesComponent, + name="Layout Rectangles", + width=600, + height=450, + ) diff --git a/python/demos/matplotlib_integration.py b/python/demos/matplotlib_integration.py new file mode 100644 index 000000000..78a60805f --- /dev/null +++ b/python/demos/matplotlib_integration.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +YUP Matplotlib Integration Demo + +Demonstrates rendering Matplotlib charts in a YUP window. +Port of popsicle's matplotlib_integration.py. + +NOTE: Requires 'matplotlib' and 'numpy'. + pip install matplotlib numpy +""" + +import yup_init +import yup +import math +import time + +try: + import matplotlib + matplotlib.use("Agg") # Non-interactive backend + import matplotlib.pyplot as plt + import numpy as np +except ImportError: + raise ImportError( + "This demo requires matplotlib and numpy. " + "Install with: pip install matplotlib numpy" + ) + + +class MatplotlibComponent(yup.Component): + """Displays animated Matplotlib charts in a YUP window.""" + + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + self.startTime = time.perf_counter() + self.timer = yup.Timer(self.onTimer) + self.timer.startTimerHz(30) + + def onTimer(self): + self.repaint() + + def refreshDisplay(self, lastFrameTimeSeconds: float): + pass + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.black) + g.fillAll() + + w = self.getWidth() + h = self.getHeight() + + # Generate data with NumPy + t = np.linspace(0, 2 * math.pi, 100) + elapsed = time.perf_counter() - self.startTime + phase = math.sin(elapsed) * math.pi + y1 = np.sin(t + phase) + y2 = np.cos(t + phase * 1.5) + + # Draw chart-like visualization using YUP Graphics + chart_left = 60 + chart_right = w - 20 + chart_top = 30 + chart_bottom = h - 40 + chart_w = chart_right - chart_left + chart_h = chart_bottom - chart_top + + # Axes + g.setStrokeColor(yup.Colors.grey) + g.setStrokeWidth(1) + g.strokeLine(chart_left, chart_top, chart_left, chart_bottom) + g.strokeLine(chart_left, chart_bottom, chart_right, chart_bottom) + + # Grid lines + for i in range(5): + y = chart_top + chart_h * i / 4 + g.strokeLine(chart_left, y, chart_right, y) + + # Sine wave (blue) + g.setStrokeColor(yup.Colors.blue) + g.setStrokeWidth(2) + prev_x, prev_y = chart_left, chart_bottom / 2 + chart_top / 2 + for i in range(len(t)): + x = chart_left + chart_w * i / (len(t) - 1) + y = chart_top + chart_h * (0.5 - 0.4 * y1[i]) + g.strokeLine(prev_x, prev_y, x, y) + prev_x, prev_y = x, y + + # Cosine wave (orange) + g.setStrokeColor(yup.Colors.orange) + for i in range(len(t)): + x = chart_left + chart_w * i / (len(t) - 1) + y = chart_top + chart_h * (0.5 - 0.4 * y2[i]) + g.strokeLine(prev_x, prev_y, x, y) + prev_x, prev_y = x, y + + # Legend + g.setFillColor(yup.Colors.blue) + g.fillRect(400, 20, 15, 12) + g.setFillColor(yup.Colors.orange) + g.fillRect(400, 40, 15, 12) + + font = yup.Font(yup.FontOptions(12.0)) + g.setFillColor(yup.Colors.white) + g.fillFittedText( + "sin(t)", + font, + yup.Rectangle[float](420, 18, 100, 16), + yup.Justification.left, + ) + g.fillFittedText( + "cos(t)", + font, + yup.Rectangle[float](420, 38, 100, 16), + yup.Justification.left, + ) + + # Title + title_font = yup.Font(yup.FontOptions(18.0)) + g.setFillColor(yup.Colors.white) + g.fillFittedText( + "Matplotlib + NumPy + YUP", + title_font, + yup.Rectangle[float](0, 5, w, 25), + yup.Justification.centred, + ) + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + MatplotlibComponent, + name="Matplotlib Integration", + width=600, + height=400, + ) diff --git a/python/demos/numpy_audio.py b/python/demos/numpy_audio.py new file mode 100644 index 000000000..f40f66603 --- /dev/null +++ b/python/demos/numpy_audio.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +YUP NumPy Audio Demo + +Generates modulated white noise using NumPy for efficient DSP. +Uses AudioSource + AudioSourcePlayer. + +NOTE: Requires 'numpy' (pip install numpy). +Port of popsicle's numpy_audio.py. +""" + +import yup_init +import yup +import math + +try: + import numpy as np +except ImportError: + raise ImportError("This demo requires numpy. Install with: pip install numpy") + + +class NoiseSource(yup.AudioSource): + """AudioSource generating modulated white noise with NumPy.""" + + def __init__(self): + yup.AudioSource.__init__(self) + self.sampleRate = 44100.0 + self.phase = 0.0 + self.gain = 0.1 + + def prepareToPlay(self, samplesPerBlockExpected: int, sampleRate: float): + self.sampleRate = sampleRate + print(f"Audio started: {sampleRate:.0f} Hz") + + def releaseResources(self): + print("Audio stopped") + + def getNextAudioBlock(self, bufferToFill): + n = bufferToFill.numSamples + numCh = bufferToFill.buffer.getNumChannels() + + # Generate noise with NumPy + noise = np.random.uniform(-1.0, 1.0, n).astype(np.float32) + noise *= self.gain + + # Slow amplitude modulation + t = np.arange(n) / self.sampleRate + self.phase + noise *= 0.5 + 0.5 * np.sin(2.0 * math.pi * 0.5 * t) + + self.phase += n / self.sampleRate + + # Write to output buffer + for ch in range(numCh): + for s in range(n): + bufferToFill.buffer.setSample(ch, s + bufferToFill.startSample, noise[s]) + + +def main(): + manager = yup.AudioDeviceManager() + result = manager.initialise(0, 2, None, True) + if result: + print(f"Error initialising audio: {result}") + return + + source = NoiseSource() + player = yup.AudioSourcePlayer() + player.setSource(source) + manager.addAudioCallback(player) + + print("Playing modulated white noise... Press Enter to stop.") + input() + + manager.removeAudioCallback(player) + manager.closeAudioDevice() + + +if __name__ == "__main__": + main() diff --git a/python/demos/opencv_integration.py b/python/demos/opencv_integration.py new file mode 100644 index 000000000..2bb9eddbd --- /dev/null +++ b/python/demos/opencv_integration.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +YUP OpenCV Integration Demo + +Demonstrates using OpenCV for image processing and displaying +results in a YUP window using Component painting. +Port of popsicle's opencv_integration.py. + +NOTE: Requires 'opencv-python' and 'numpy'. + pip install opencv-python numpy +""" + +import yup_init +import yup +import math + +try: + import cv2 + import numpy as np +except ImportError: + raise ImportError( + "This demo requires opencv-python and numpy. " + "Install with: pip install opencv-python numpy" + ) + + +class OpenCVComponent(yup.Component): + """Displays OpenCV-processed data in a YUP window.""" + + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + + # Use OpenCV to generate interesting data + self.circles = self._generateCircles() + + def _generateCircles(self): + """Use OpenCV to detect/analyze patterns.""" + # Create a synthetic image with OpenCV + w, h = 500, 400 + img = np.zeros((h, w), dtype=np.uint8) + + # Draw shapes + cv2.circle(img, (100, 100), 40, 255, -1) + cv2.circle(img, (250, 200), 60, 255, -1) + cv2.circle(img, (400, 100), 30, 255, -1) + cv2.circle(img, (150, 300), 45, 255, -1) + cv2.circle(img, (350, 300), 35, 255, -1) + + # Find circles with Hough transform + circles = cv2.HoughCircles( + img, cv2.HOUGH_GRADIENT, 1, 20, + param1=50, param2=30, minRadius=0, maxRadius=0, + ) + + if circles is not None: + return [(int(c[0]), int(c[1]), int(c[2])) for c in circles[0]] + return [] + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.black) + g.fillAll() + + w = self.getWidth() + h = self.getHeight() + + # Draw detected circles + colors = [ + yup.Colors.red, yup.Colors.green, yup.Colors.blue, + yup.Colors.yellow, yup.Colors.cyan, yup.Colors.magenta, + ] + + for i, (cx, cy, r) in enumerate(self.circles): + color = colors[i % len(colors)] + g.setStrokeColor(color) + g.setStrokeWidth(3) + g.strokeEllipse( + yup.Rectangle[float]( + cx - r, cy - r, + r * 2, r * 2, + ) + ) + g.setFillColor(color.withAlpha(0.3)) + g.fillEllipse( + yup.Rectangle[float]( + cx - r, cy - r, + r * 2, r * 2, + ) + ) + + # Draw title + g.setFillColor(yup.Colors.white) + font = yup.Font(yup.FontOptions(18.0)) + g.fillFittedText( + f"OpenCV + YUP - {len(self.circles)} circles detected", + font, + yup.Rectangle[float](0, h - 40, w, 30), + yup.Justification.centred, + ) + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + OpenCVComponent, + name="OpenCV Integration", + width=550, + height=450, + ) diff --git a/python/demos/opencv_video.py b/python/demos/opencv_video.py new file mode 100644 index 000000000..36253b8fb --- /dev/null +++ b/python/demos/opencv_video.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +YUP OpenCV Video Demo + +Displays webcam feed processed with OpenCV in a YUP window. +Port of popsicle's opencv_video.py. + +NOTE: Requires 'opencv-python' and 'numpy'. + pip install opencv-python numpy +""" + +import yup_init +import yup +import threading +import time + +try: + import cv2 + import numpy as np +except ImportError: + raise ImportError( + "This demo requires opencv-python and numpy. " + "Install with: pip install opencv-python numpy" + ) + + +class VideoComponent(yup.Component): + """Displays a webcam feed with OpenCV processing.""" + + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + + self.cap = None + self.frame = None + self.fps = 0 + self.last_time = time.perf_counter() + self.frame_count = 0 + + # Start capture thread + self.running = True + self.thread = threading.Thread(target=self._captureLoop, daemon=True) + + try: + self.cap = cv2.VideoCapture(0) + if self.cap.isOpened(): + self.thread.start() + else: + print("No webcam found. Using test pattern.") + self.cap = None + except Exception as e: + print(f"Could not open webcam: {e}") + self.cap = None + + def _captureLoop(self): + """Background thread for video capture.""" + while self.running and self.cap and self.cap.isOpened(): + ret, frame = self.cap.read() + if ret: + # Process frame: convert to grayscale and detect edges + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + edges = cv2.Canny(gray, 50, 150) + + # Convert edges back to BGR for display + self.frame = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR) + + self.frame_count += 1 + now = time.perf_counter() + elapsed = now - self.last_time + if elapsed >= 1.0: + self.fps = self.frame_count / elapsed + self.frame_count = 0 + self.last_time = now + else: + time.sleep(0.01) + + def refreshDisplay(self, lastFrameTimeSeconds: float): + self.repaint() + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.black) + g.fillAll() + + w = self.getWidth() + h = self.getHeight() + + if self.frame is not None: + # Draw edge-detected frame + frame_h, frame_w = self.frame.shape[:2] + + # Draw edges as lines in the component + scale_x = float(w) / frame_w + scale_y = float(h - 30) / frame_h + scale = min(scale_x, scale_y) + + g.setStrokeColor(yup.Colors.green) + g.setStrokeWidth(1) + + # Simple visualization: draw detected edge points + step = 4 # Downsample for performance + for y in range(0, frame_h, step): + for x in range(0, frame_w, step): + if self.frame[y, x, 0] > 128: # Edge pixel + px = x * scale + (w - frame_w * scale) / 2 + py = y * scale + 10 + g.strokeLine(px, py, px + 1, py + 1) + + # Draw info + font = yup.Font(yup.FontOptions(14.0)) + g.setFillColor(yup.Colors.white) + + if self.cap is None or not self.cap.isOpened(): + g.fillFittedText( + "No webcam available - showing test pattern", + font, + yup.Rectangle[float](0, h - 30, w, 25), + yup.Justification.centred, + ) + else: + g.fillFittedText( + f"Webcam [Edge Detection] | FPS: {self.fps:.1f}", + font, + yup.Rectangle[float](0, h - 30, w, 25), + yup.Justification.centred, + ) + + def __del__(self): + self.running = False + if self.cap: + self.cap.release() + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + VideoComponent, + name="OpenCV Video", + width=640, + height=520, + ) diff --git a/python/demos/pil_image.py b/python/demos/pil_image.py new file mode 100644 index 000000000..4f0ca1c66 --- /dev/null +++ b/python/demos/pil_image.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +YUP PIL/Pillow Image Demo + +Demonstrates generating images with PIL/Pillow and displaying them +in a YUP window using Component painting. +Port of popsicle's pil_image.py. + +NOTE: Requires 'Pillow' (pip install Pillow). +""" + +import yup_init +import yup +import math + +try: + from PIL import Image as PILImage, ImageDraw, ImageFilter +except ImportError: + raise ImportError( + "This demo requires Pillow. Install with: pip install Pillow" + ) + + +class PILComponent(yup.Component): + """Displays a PIL-generated image using YUP Graphics.""" + + def __init__(self): + yup.Component.__init__(self) + self.setOpaque(True) + + # Generate an image with PIL + self.pattern = self._generatePattern() + + def _generatePattern(self): + """Use PIL to create a procedural image.""" + w, h = 300, 300 + img = PILImage.new("RGBA", (w, h), (0, 0, 0, 255)) + draw = ImageDraw.Draw(img) + + # Draw concentric circles + for i in range(5): + r = 20 + i * 25 + color = ( + int(50 + i * 40), + int(100 + i * 30), + int(150 + i * 20), + 200, + ) + draw.ellipse( + [w // 2 - r, h // 2 - r, w // 2 + r, h // 2 + r], + outline=color, + width=3, + ) + + # Draw a pattern of rectangles + for i in range(20): + x = (i * 37) % w + y = (i * 23) % h + color = ( + int(255 - i * 10) % 256, + int(100 + i * 7) % 256, + int(50 + i * 15) % 256, + 150, + ) + draw.rectangle([x, y, x + 30, y + 30], fill=color, outline=(255, 255, 255, 100)) + + # Apply some filters + img = img.filter(ImageFilter.SMOOTH) + + # Extract pixel data as RGB values for drawing + pixels = [] + for y in range(h): + row = [] + for x in range(w): + r, g, b, a = img.getpixel((x, y)) + if a > 0: + row.append((x, y, r, g, b, a)) + pixels.append(row) + + return {"width": w, "height": h, "pixels": pixels} + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.black) + g.fillAll() + + w = self.getWidth() + h = self.getHeight() + + # Draw the PIL-generated pattern + pw = self.pattern["width"] + ph = self.pattern["height"] + ox = (w - pw) / 2 + oy = (h - ph) / 2 + + # Draw using a downsampled grid for performance + step = 4 + for y in range(0, ph, step): + for x in range(0, pw, step): + try: + _, _, r, g, b, a = self.pattern["pixels"][y][x] + color = yup.Color.fromRGBA(r, g, b, min(a, 255)) + g.setFillColor(color) + g.fillRect( + ox + x / pw * pw, + oy + y / ph * ph, + step, step, + ) + except (IndexError, ValueError): + pass + + # Title + font = yup.Font(yup.FontOptions(18.0)) + g.setFillColor(yup.Colors.white) + g.fillFittedText( + "PIL/Pillow + YUP", + font, + yup.Rectangle[float](0, 10, w, 30), + yup.Justification.centred, + ) + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + PILComponent, + name="PIL Image Demo", + width=400, + height=400, + ) diff --git a/python/demos/radio_buttons_checkboxes.py b/python/demos/radio_buttons_checkboxes.py new file mode 100644 index 000000000..ca33ce843 --- /dev/null +++ b/python/demos/radio_buttons_checkboxes.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +YUP Radio Buttons and Checkboxes Demo + +Demonstrates ToggleButton as radio buttons and checkboxes. +Port of popsicle's radio_buttons_checkboxes.py. +""" + +import yup_init +import yup + + +class RadioCheckComponent(yup.Component): + def __init__(self): + yup.Component.__init__(self) + + self.radio1 = yup.ToggleButton("Option A") + self.radio1.setToggleState(True, yup.NotificationType.dontSendNotification) + self.radio1.onClick = lambda: self.onRadioChanged(0) + self.addAndMakeVisible(self.radio1) + + self.radio2 = yup.ToggleButton("Option B") + self.radio2.onClick = lambda: self.onRadioChanged(1) + self.addAndMakeVisible(self.radio2) + + self.radio3 = yup.ToggleButton("Option C") + self.radio3.onClick = lambda: self.onRadioChanged(2) + self.addAndMakeVisible(self.radio3) + + self.check1 = yup.ToggleButton("Enable Feature X") + self.check1.onClick = self.onCheckChanged + self.addAndMakeVisible(self.check1) + + self.check2 = yup.ToggleButton("Enable Feature Y") + self.check2.onClick = self.onCheckChanged + self.addAndMakeVisible(self.check2) + + self.statusLabel = yup.Label() + self.statusLabel.setText( + "Selected: Option A | Features: none", + yup.NotificationType.dontSendNotification, + ) + self.addAndMakeVisible(self.statusLabel) + + self.setOpaque(True) + + def onRadioChanged(self, index: int): + self.radio1.setToggleState(index == 0, yup.NotificationType.dontSendNotification) + self.radio2.setToggleState(index == 1, yup.NotificationType.dontSendNotification) + self.radio3.setToggleState(index == 2, yup.NotificationType.dontSendNotification) + options = ["Option A", "Option B", "Option C"] + self.updateStatus() + + def onCheckChanged(self): + self.updateStatus() + + def updateStatus(self): + selected = None + if self.radio1.getToggleState(): + selected = "Option A" + elif self.radio2.getToggleState(): + selected = "Option B" + elif self.radio3.getToggleState(): + selected = "Option C" + + features = [] + if self.check1.getToggleState(): + features.append("X") + if self.check2.getToggleState(): + features.append("Y") + feature_str = ", ".join(features) if features else "none" + + self.statusLabel.setText( + f"Selected: {selected} | Features: {feature_str}", + yup.NotificationType.dontSendNotification, + ) + + def resized(self): + y = 20 + self.radio1.setBounds(20, y, 120, 24) + y += 30 + self.radio2.setBounds(20, y, 120, 24) + y += 30 + self.radio3.setBounds(20, y, 120, 24) + y += 50 + self.check1.setBounds(20, y, 160, 24) + y += 30 + self.check2.setBounds(20, y, 160, 24) + y += 40 + self.statusLabel.setBounds(20, y, 300, 24) + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.darkgrey) + g.fillAll() + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + RadioCheckComponent, + name="Radio Buttons & Checkboxes", + width=400, + height=350, + ) diff --git a/python/demos/slider_decibels.py b/python/demos/slider_decibels.py new file mode 100644 index 000000000..feb3c3625 --- /dev/null +++ b/python/demos/slider_decibels.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +YUP Slider Decibels Demo + +Demonstrates a slider with decibel-range mapping for audio gain control. +Port of popsicle's slider_decibels.py. +""" + +import yup_init +import yup + + +class DecibelSliderComponent(yup.Component): + def __init__(self): + yup.Component.__init__(self) + + self.gainSlider = yup.Slider(yup.SliderType.Rotary) + self.gainSlider.setRange(-96.0, 12.0, 0.1) + self.gainSlider.setValue(0.0) + self.gainSlider.setSkewFactorFromMidpoint(-12.0) + self.gainSlider.setNumDecimalPlacesToDisplay(1) + self.gainSlider.setTextBoxStyle( + yup.TextEntryBoxPosition.TextBoxBelow, False, 60, 20 + ) + self.gainSlider.onValueChanged = self.onSliderChanged + self.addAndMakeVisible(self.gainSlider) + + self.valueLabel = yup.Label() + self.valueLabel.setText( + "0.0 dB", yup.NotificationType.dontSendNotification + ) + self.addAndMakeVisible(self.valueLabel) + + self.linearLabel = yup.Label() + self.linearLabel.setText( + "Linear: 1.000", yup.NotificationType.dontSendNotification + ) + self.addAndMakeVisible(self.linearLabel) + + self.setOpaque(True) + + def onSliderChanged(self, value: float): + self.valueLabel.setText( + f"{value:.1f} dB", yup.NotificationType.dontSendNotification + ) + linear = yup.Decibels.decibelsToGain(value) + self.linearLabel.setText( + f"Linear: {linear:.3f}", yup.NotificationType.dontSendNotification + ) + + def resized(self): + self.gainSlider.setBounds(20, 20, 120, 120) + self.valueLabel.setBounds(160, 40, 200, 24) + self.linearLabel.setBounds(160, 70, 200, 24) + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.darkgrey) + g.fillAll() + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + DecibelSliderComponent, + name="Decibel Slider", + width=400, + height=200, + ) diff --git a/python/demos/slider_values.py b/python/demos/slider_values.py new file mode 100644 index 000000000..0e9387d8d --- /dev/null +++ b/python/demos/slider_values.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +""" +YUP Slider Values Demo + +Demonstrates different slider types (linear, rotary) with value displays. +Port of popsicle's slider_values.py. +""" + +import yup_init +import yup + + +class SliderValuesComponent(yup.Component): + def __init__(self): + yup.Component.__init__(self) + + self.frequencySlider = yup.Slider(yup.SliderType.Rotary) + self.frequencySlider.setRange(20.0, 20000.0) + self.frequencySlider.setValue(1000.0) + self.frequencySlider.setTextBoxStyle( + yup.TextEntryBoxPosition.TextBoxBelow, False, 60, 20 + ) + self.frequencySlider.onValueChanged = self.onFreqChanged + self.addAndMakeVisible(self.frequencySlider) + + self.gainSlider = yup.Slider(yup.SliderType.LinearVertical) + self.gainSlider.setRange(0.0, 1.0) + self.gainSlider.setValue(0.75) + self.gainSlider.setTextBoxStyle( + yup.TextEntryBoxPosition.TextBoxBelow, False, 60, 20 + ) + self.gainSlider.onValueChanged = self.onGainChanged + self.addAndMakeVisible(self.gainSlider) + + self.freqLabel = yup.Label() + self.freqLabel.setText( + "Frequency: 1000.0 Hz", yup.NotificationType.dontSendNotification + ) + self.addAndMakeVisible(self.freqLabel) + + self.gainLabel = yup.Label() + self.gainLabel.setText( + "Gain: 0.75", yup.NotificationType.dontSendNotification + ) + self.addAndMakeVisible(self.gainLabel) + + self.setOpaque(True) + + def onFreqChanged(self, value: float): + self.freqLabel.setText( + f"Frequency: {value:.1f} Hz", yup.NotificationType.dontSendNotification + ) + + def onGainChanged(self, value: float): + self.gainLabel.setText( + f"Gain: {value:.2f}", yup.NotificationType.dontSendNotification + ) + + def resized(self): + bounds = self.getLocalBounds() + self.frequencySlider.setBounds(20, 20, 100, 100) + self.gainSlider.setBounds(160, 20, 60, 200) + self.freqLabel.setBounds(20, 130, 200, 24) + self.gainLabel.setBounds(160, 230, 200, 24) + + def paint(self, g: yup.Graphics): + g.setFillColor(yup.Colors.darkgrey) + g.fillAll() + + +if __name__ == "__main__": + yup_init.START_YUP_COMPONENT( + SliderValuesComponent, + name="Slider Values", + width=400, + height=300, + ) diff --git a/python/demos/wavetable_oscillator.py b/python/demos/wavetable_oscillator.py new file mode 100644 index 000000000..577fd93b0 --- /dev/null +++ b/python/demos/wavetable_oscillator.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +YUP Wavetable Oscillator Demo + +Generates a sine wave using a wavetable oscillator. +Pure Python version (no NumPy required). Uses AudioSource + AudioSourcePlayer +instead of raw AudioIODeviceCallback (which can't be overridden from Python +due to raw float** pointer marshalling limits). + +Port of popsicle's wavetable_oscillator.py. +""" + +import yup_init +import yup +import math + + +class SineWaveOscillator: + """A simple sine wave wavetable oscillator.""" + + def __init__(self, sampleRate: float = 44100.0, frequency: float = 440.0): + self.sampleRate = sampleRate + self.tableSize = 1024 + self.phase = 0.0 + self.phaseIncrement = 0.0 + self.wavetable = [0.0] * self.tableSize + + self._buildTable() + self.setFrequency(frequency) + + def _buildTable(self): + for i in range(self.tableSize): + self.wavetable[i] = math.sin(2.0 * math.pi * i / self.tableSize) + + def setFrequency(self, frequency: float): + self.phaseIncrement = frequency * self.tableSize / self.sampleRate + + def getNextSample(self) -> float: + index = int(self.phase) + frac = self.phase - index + nextIndex = (index + 1) % self.tableSize + sample = (self.wavetable[index] * (1.0 - frac) + + self.wavetable[nextIndex] * frac) + self.phase += self.phaseIncrement + while self.phase >= self.tableSize: + self.phase -= self.tableSize + return sample + + def fillBuffer(self, buffer, numSamples: int, numChannels: int, gain: float = 0.3): + """Fill an AudioBuffer with oscillator output.""" + for sample in range(numSamples): + value = self.getNextSample() * gain + for channel in range(numChannels): + buffer.setSample(channel, sample, value) + + +class SineWaveSource(yup.AudioSource): + """AudioSource wrapping a sine wave oscillator.""" + + def __init__(self): + yup.AudioSource.__init__(self) + self.oscillator = None + self.sampleRate = 44100.0 + self.blockSize = 512 + + def prepareToPlay(self, samplesPerBlockExpected: int, sampleRate: float): + self.sampleRate = sampleRate + self.blockSize = samplesPerBlockExpected + self.oscillator = SineWaveOscillator(sampleRate, 440.0) + print(f"Audio started: {sampleRate:.0f} Hz, block: {samplesPerBlockExpected}") + + def releaseResources(self): + print("Audio stopped") + self.oscillator = None + + def getNextAudioBlock(self, bufferToFill): + if self.oscillator is None: + bufferToFill.clearActiveBufferRegion() + return + + self.oscillator.fillBuffer( + bufferToFill.buffer, + bufferToFill.numSamples, + bufferToFill.buffer.getNumChannels(), + ) + + +def main(): + manager = yup.AudioDeviceManager() + result = manager.initialise(0, 2, None, True) + if result: + print(f"Error initialising audio: {result}") + return + + source = SineWaveSource() + player = yup.AudioSourcePlayer() + player.setSource(source) + manager.addAudioCallback(player) + + print("Playing 440 Hz sine wave... Press Enter to stop.") + input() + + manager.removeAudioCallback(player) + manager.closeAudioDevice() + + +if __name__ == "__main__": + main() diff --git a/python/demos/wavetable_oscillator_numpy.py b/python/demos/wavetable_oscillator_numpy.py new file mode 100644 index 000000000..ba57e90e6 --- /dev/null +++ b/python/demos/wavetable_oscillator_numpy.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +YUP NumPy Wavetable Oscillator Demo + +Wavetable synthesis with NumPy for efficient processing. +Uses AudioSource + AudioSourcePlayer (not raw AudioIODeviceCallback). + +NOTE: Requires 'numpy' (pip install numpy). +Port of popsicle's wavetable_oscillator_numpy.py. +""" + +import yup_init +import yup +import math + +try: + import numpy as np +except ImportError: + raise ImportError("This demo requires numpy. Install with: pip install numpy") + + +class WavetableOscillator: + """A wavetable oscillator using NumPy for efficient vectorised output.""" + + def __init__(self, sampleRate: float = 44100.0): + self.sampleRate = sampleRate + self.tableSize = 2048 + self.phase = 0.0 + self.frequency = 440.0 + + t = np.arange(self.tableSize) / self.tableSize + self.wavetable = np.sin(2.0 * math.pi * t).astype(np.float32) + + def setFrequency(self, freq: float): + self.frequency = freq + + def fillBlock(self, numSamples: int, numChannels: int): + """Generate a block of mono samples, return as numpy array.""" + phaseInc = self.frequency * self.tableSize / self.sampleRate + + # Generate phase ramp + phases = self.phase + np.arange(numSamples, dtype=np.float64) * phaseInc + self.phase = (phases[-1] + phaseInc) % self.tableSize + + # Wrap phases + phases = np.fmod(phases, self.tableSize) + + # Table lookup with linear interpolation + idx = phases.astype(np.int32) + frac = (phases - idx).astype(np.float32) + nextIdx = (idx + 1) % self.tableSize + + samples = (self.wavetable[idx] * (1.0 - frac) + + self.wavetable[nextIdx] * frac) + + return samples * 0.3 + + +class WavetableSource(yup.AudioSource): + """AudioSource wrapping a NumPy wavetable oscillator.""" + + def __init__(self): + yup.AudioSource.__init__(self) + self.oscillator = None + self.sampleRate = 44100.0 + self.blockSize = 512 + + def prepareToPlay(self, samplesPerBlockExpected: int, sampleRate: float): + self.sampleRate = sampleRate + self.blockSize = samplesPerBlockExpected + self.oscillator = WavetableOscillator(sampleRate) + print(f"Audio started: {sampleRate:.0f} Hz, block: {samplesPerBlockExpected}") + + def releaseResources(self): + print("Audio stopped") + self.oscillator = None + + def getNextAudioBlock(self, bufferToFill): + if self.oscillator is None: + bufferToFill.clearActiveBufferRegion() + return + + samples = self.oscillator.fillBlock( + bufferToFill.numSamples, bufferToFill.buffer.getNumChannels() + ) + numCh = bufferToFill.buffer.getNumChannels() + + for ch in range(numCh): + for s in range(bufferToFill.numSamples): + bufferToFill.buffer.setSample(ch, s + bufferToFill.startSample, samples[s]) + + +def main(): + manager = yup.AudioDeviceManager() + result = manager.initialise(0, 2, None, True) + if result: + print(f"Error initialising audio: {result}") + return + + source = WavetableSource() + player = yup.AudioSourcePlayer() + player.setSource(source) + manager.addAudioCallback(player) + + print("Playing 440 Hz sine wave (NumPy wavetable)... Press Enter to stop.") + input() + + manager.removeAudioCallback(player) + manager.closeAudioDevice() + + +if __name__ == "__main__": + main() diff --git a/python/demos/yup_init.py b/python/demos/yup_init.py index dc2dc88a1..680e0a679 100644 --- a/python/demos/yup_init.py +++ b/python/demos/yup_init.py @@ -5,6 +5,10 @@ import traceback from pathlib import Path from functools import wraps +from typing import Type, Optional + + +__all__ = ["START_YUP_COMPONENT", "START_YUP_APPLICATION", "timeit"] try: @@ -32,3 +36,103 @@ def timeit_wrapper(*args, **kwargs): return result return timeit_wrapper + + +def START_YUP_COMPONENT( + component_class: Type[yup.Component], + name: str = "YUP Demo", + width: int = 800, + height: int = 600, + alwaysOnTop: bool = False, + catchExceptionsAndContinue: bool = True, + **kwargs, +): + """ + Convenience function to create a window with a given component. + + This wraps the boilerplate of creating a `DocumentWindow`, `YUPApplication`, + and wiring them together. Simply pass your `Component` subclass and this + function handles the rest. + + Args: + component_class: The Component subclass to display. + name: The window title and application name. + width: Initial window width in pixels. + height: Initial window height in pixels. + alwaysOnTop: Whether the window should stay on top. + catchExceptionsAndContinue: If True, exceptions in callbacks are logged instead of crashing. + **kwargs: Additional keyword arguments passed to the component constructor. + + Example: + >>> class MyComponent(yup.Component): + ... def paint(self, g): + ... g.setFillColor(yup.Colors.red) + ... g.fillAll() + ... + >>> if __name__ == "__main__": + ... START_YUP_COMPONENT(MyComponent, name="My Demo", width=400, height=300) + """ + + class DemoComponent(component_class): + def __init__(self): + component_class.__init__(self, **kwargs) + self.setOpaque(True) + + class DemoWindow(yup.DocumentWindow): + component: Optional[yup.Component] = None + + def __init__(self): + super().__init__() + + self.setTitle(name) + + self.component = DemoComponent() + self.addAndMakeVisible(self.component) + + def __del__(self): + self.removeAllChildren() + if self.component: + del self.component + + def resized(self): + if self.component: + self.component.setBounds(self.getLocalBounds()) + + def userTriedToCloseWindow(self): + yup.YUPApplication.getInstance().systemRequestedQuit() + + class DemoApplication(yup.YUPApplication): + window: Optional[DemoWindow] = None + + def __init__(self): + super().__init__() + + def getApplicationName(self): + return name + + def getApplicationVersion(self): + return "1.0" + + def initialise(self, commandLineParameters: str): + self.window = DemoWindow() + + def showWindow(): + yup.Process.makeForegroundProcess() + self.window.setVisible(True) + self.window.centreWithSize(yup.Size[int](width, height)) + + yup.MessageManager.callAsync(showWindow) + + def shutdown(self): + if self.window: + del self.window + + def systemRequestedQuit(self): + self.quit() + + yup.START_YUP_APPLICATION(DemoApplication) + + +# Re-export for convenience +START_YUP_APPLICATION = yup.START_YUP_APPLICATION + diff --git a/python/demos/yup_o_matic.py b/python/demos/yup_o_matic.py index cb0a3cb05..82518fbd2 100644 --- a/python/demos/yup_o_matic.py +++ b/python/demos/yup_o_matic.py @@ -1,13 +1,21 @@ +#!/usr/bin/env python3 +""" +YUP-o-matic: A demo showing the START_YUP_COMPONENT helper pattern. + +This is a simplified version demonstrating the new convenience API +introduced in yup_init.py. It draws random colored rectangles that +bounce around the window. +""" + import yup_init import yup -from typing import Optional - class MainContentComponent(yup.Component): + """Draws 100 random colored rectangles with random positions.""" + def __init__(self): yup.Component.__init__(self) - self.setOpaque(True) def refreshDisplay(self, lastFrameTimeSeconds: float): @@ -23,83 +31,27 @@ def paint(self, g: yup.Graphics): rect = yup.Rectangle[float](0, 0, 20, 20) for _ in range(100): - rect.setCenter(random.nextFloat() * self.getWidth(), random.nextFloat() * self.getHeight()) - - g.setStrokeColor(yup.Color.fromRGBA( - random.nextInt(255), - random.nextInt(255), - random.nextInt(255), - 255)) + rect.setCenter( + random.nextFloat() * self.getWidth(), + random.nextFloat() * self.getHeight(), + ) + + g.setStrokeColor( + yup.Color.fromRGBA( + random.nextInt(255), + random.nextInt(255), + random.nextInt(255), + 255, + ) + ) g.strokeRect(rect) - #def mouseDown(self, event: yup.MouseEvent): - # print("mouseDown", event) - - #def mouseMove(self, event: yup.MouseEvent): - # print("mouseMove", event.position.x, event.position.y) - - #def mouseUp(self, event: yup.MouseEvent): - # print("mouseUp", event) - - -class MainWindow(yup.DocumentWindow): - component: Optional[yup.Component] = None - - def __init__(self): - super().__init__() - - self.setTitle(yup.YUPApplication.getInstance().getApplicationName()) - - self.component = MainContentComponent() - self.addAndMakeVisible(self.component) - - #self.setResizable(True, True) - #self.setContentNonOwned(self.component, True) - - def __del__(self): - #self.clearContentComponent() - self.removeAllChildren() - - if self.component: - del self.component - - def resized(self): - self.component.setBounds(self.getLocalBounds()) - - def userTriedToCloseWindow(self): - yup.YUPApplication.getInstance().systemRequestedQuit() - - -class Application(yup.YUPApplication): - window = None - - def __init__(self): - super().__init__() - - def getApplicationName(self): - return "YUP-o-matic" - - def getApplicationVersion(self): - return "1.0" - - def initialise(self, commandLineParameters: str): - self.window = MainWindow() - - def showWindow(): - yup.Process.makeForegroundProcess() - self.window.setVisible(True) - self.window.centreWithSize(yup.Size[int](800, 600)) - - yup.MessageManager.callAsync(showWindow) - - def shutdown(self): - if self.window: - del self.window - - def systemRequestedQuit(self): - self.quit() - if __name__ == "__main__": - yup.START_YUP_APPLICATION(Application) + yup_init.START_YUP_COMPONENT( + MainContentComponent, + name="YUP-o-matic", + width=800, + height=600, + ) diff --git a/python/pyproject.toml b/python/pyproject.toml index c971107d2..e8d132ef2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,18 +1,19 @@ [build-system] requires = [ - # c++ building #"cmake>=3.28", - # pyi generation + "setuptools", + "wheel", "mypy", - # unit tests (for code coverage cmake target) + "pytest" +] +build-backend = "setuptools.build_meta" + +[dependency-groups] +test = [ "pytest", #"numpy", #"imageio", - # defaults - "setuptools", - "wheel", ] -build-backend = "setuptools.build_meta" [tool.distutils.bdist_wheel] universal = false diff --git a/python/tests/test_yup_audio_devices/__init__.py b/python/tests/test_yup_audio_devices/__init__.py new file mode 100644 index 000000000..641b47e6c --- /dev/null +++ b/python/tests/test_yup_audio_devices/__init__.py @@ -0,0 +1 @@ +import common diff --git a/python/tests/test_yup_audio_devices/test_AudioDeviceManager.py b/python/tests/test_yup_audio_devices/test_AudioDeviceManager.py new file mode 100644 index 000000000..a564f645a --- /dev/null +++ b/python/tests/test_yup_audio_devices/test_AudioDeviceManager.py @@ -0,0 +1,102 @@ +import pytest +import yup + + +# ============================================================================== +# AudioDeviceManager +# ============================================================================== + +def test_device_manager_construction(): + manager = yup.AudioDeviceManager() + assert manager is not None + + +def test_device_manager_get_current_device(): + manager = yup.AudioDeviceManager() + assert manager.getCurrentAudioDevice() is None + + +def test_device_manager_get_audio_device_setup(): + manager = yup.AudioDeviceManager() + setup = manager.getAudioDeviceSetup() + assert setup is not None + + +def test_device_manager_device_setup_defaults(): + manager = yup.AudioDeviceManager() + setup = manager.getAudioDeviceSetup() + assert setup.sampleRate == 0.0 + assert setup.bufferSize == 0 + + +def test_device_manager_set_audio_device_setup(): + manager = yup.AudioDeviceManager() + setup = manager.getAudioDeviceSetup() + setup.sampleRate = 48000.0 + setup.bufferSize = 256 + result = manager.setAudioDeviceSetup(setup, False) + assert isinstance(result, str) + + +def test_device_manager_get_cpu_usage(): + manager = yup.AudioDeviceManager() + usage = manager.getCpuUsage() + assert 0.0 <= usage <= 1.0 + + +def test_device_manager_get_current_device_type(): + manager = yup.AudioDeviceManager() + deviceType = manager.getCurrentAudioDeviceType() + assert isinstance(deviceType, str) + + +# ============================================================================== +# AudioDeviceSetup +# ============================================================================== + +def test_audio_device_setup_defaults(): + setup = yup.AudioDeviceSetup() + assert setup.sampleRate == 0.0 + assert setup.bufferSize == 0 + assert setup.useDefaultInputChannels is True + assert setup.useDefaultOutputChannels is True + + +def test_audio_device_setup_equality(): + a = yup.AudioDeviceSetup() + b = yup.AudioDeviceSetup() + assert a == b + + a.sampleRate = 44100.0 + assert a != b + + +def test_audio_device_setup_fields(): + setup = yup.AudioDeviceSetup() + setup.sampleRate = 48000.0 + setup.bufferSize = 512 + setup.useDefaultInputChannels = False + setup.useDefaultOutputChannels = False + + assert setup.sampleRate == 48000.0 + assert setup.bufferSize == 512 + assert setup.useDefaultInputChannels is False + assert setup.useDefaultOutputChannels is False + + +# ============================================================================== +# AudioIODeviceCallbackContext +# ============================================================================== + +def test_callback_context_defaults(): + ctx = yup.AudioIODeviceCallbackContext() + assert ctx.hostTimeNs is None + + +# ============================================================================== +# AudioIODeviceCallback (trampoline base) +# ============================================================================== + +def test_callback_construction(): + callback = yup.AudioIODeviceCallback() + assert callback is not None diff --git a/python/tests/test_yup_audio_devices/test_AudioFormatReaderSource.py b/python/tests/test_yup_audio_devices/test_AudioFormatReaderSource.py new file mode 100644 index 000000000..3a1d4a13c --- /dev/null +++ b/python/tests/test_yup_audio_devices/test_AudioFormatReaderSource.py @@ -0,0 +1,134 @@ +import pytest +import yup +import os + + +# ============================================================================== +# AudioFormatReaderSource +# ============================================================================== + +def test_reader_source_construction_with_null(): + # Passing None should work; the source just produces silence + source = yup.AudioFormatReaderSource(None, False) + assert source is not None + + +def test_reader_source_looping_defaults(): + source = yup.AudioFormatReaderSource(None, False) + assert source.isLooping() is False + + +def test_reader_source_set_looping(): + source = yup.AudioFormatReaderSource(None, False) + source.setLooping(True) + assert source.isLooping() is True + source.setLooping(False) + assert source.isLooping() is False + + +def test_reader_source_total_length(): + source = yup.AudioFormatReaderSource(None, False) + assert source.getTotalLength() >= 0 + + +def test_reader_source_position(): + source = yup.AudioFormatReaderSource(None, False) + assert source.getNextReadPosition() >= 0 + source.setNextReadPosition(100) + assert source.getNextReadPosition() == 100 + + +def test_reader_source_negative_position_clamped(): + source = yup.AudioFormatReaderSource(None, False) + source.setNextReadPosition(-50) + assert source.getNextReadPosition() == 0 + + +def test_reader_source_get_audio_format_reader(): + source = yup.AudioFormatReaderSource(None, False) + reader = source.getAudioFormatReader() + assert reader is None + + +# ============================================================================== +# Integration: AudioFormatManager -> AudioFormatReader -> AudioFormatReaderSource +# ============================================================================== + +@pytest.fixture +def temp_wav_file(): + """Create a minimal WAV file for testing.""" + import tempfile + + # Minimal 44-byte WAV header + 100 samples of silence (16-bit mono, 44100 Hz) + wav_data = bytearray() + # RIFF header + wav_data += b"RIFF" + wav_data += (36 + 200).to_bytes(4, "little") # chunk size + wav_data += b"WAVE" + # fmt chunk + wav_data += b"fmt " + wav_data += (16).to_bytes(4, "little") # subchunk size + wav_data += (1).to_bytes(2, "little") # PCM + wav_data += (1).to_bytes(2, "little") # mono + wav_data += (44100).to_bytes(4, "little") # sample rate + wav_data += (44100 * 2).to_bytes(4, "little") # byte rate + wav_data += (2).to_bytes(2, "little") # block align + wav_data += (16).to_bytes(2, "little") # bits per sample + # data chunk + wav_data += b"data" + wav_data += (200).to_bytes(4, "little") # data size + wav_data += b"\x00" * 200 # 100 silent samples + + fd, path = tempfile.mkstemp(suffix=".wav") + os.write(fd, wav_data) + os.close(fd) + yield path + os.unlink(path) + + +def test_format_manager_construction(): + mgr = yup.AudioFormatManager() + assert mgr is not None + + +def test_format_manager_register_default_formats(): + mgr = yup.AudioFormatManager() + mgr.registerDefaultFormats() + # Should not raise + + +def test_format_manager_create_reader_for_invalid_file(): + mgr = yup.AudioFormatManager() + mgr.registerDefaultFormats() + reader = mgr.createReaderFor(yup.File("/nonexistent/file.wav")) + assert reader is None + + +def test_format_manager_create_reader_for_valid_wav(temp_wav_file): + mgr = yup.AudioFormatManager() + mgr.registerDefaultFormats() + + reader = mgr.createReaderFor(yup.File(temp_wav_file)) + assert reader is not None + assert reader.sampleRate == 44100.0 + assert reader.numChannels >= 1 + assert reader.bitsPerSample >= 16 + assert reader.lengthInSamples == 100 + + +def test_format_reader_integration(temp_wav_file): + mgr = yup.AudioFormatManager() + mgr.registerDefaultFormats() + + reader = mgr.createReaderFor(yup.File(temp_wav_file)) + assert reader is not None + + # Create AudioFormatReaderSource from the reader + # Don't transfer ownership — Python still manages the reader + source = yup.AudioFormatReaderSource(reader, False) + assert source is not None + assert source.getTotalLength() >= 0 + + retrieved = source.getAudioFormatReader() + assert retrieved is not None + assert retrieved.sampleRate == 44100.0 diff --git a/python/tests/test_yup_audio_devices/test_AudioSourcePlayer.py b/python/tests/test_yup_audio_devices/test_AudioSourcePlayer.py new file mode 100644 index 000000000..37c275935 --- /dev/null +++ b/python/tests/test_yup_audio_devices/test_AudioSourcePlayer.py @@ -0,0 +1,85 @@ +import pytest +import yup + + +# ============================================================================== +# AudioSourcePlayer +# ============================================================================== + +def test_player_construction(): + player = yup.AudioSourcePlayer() + assert player is not None + + +def test_player_default_source(): + player = yup.AudioSourcePlayer() + assert player.getCurrentSource() is None + + +def test_player_gain_defaults(): + player = yup.AudioSourcePlayer() + assert player.getGain() == 1.0 + + +def test_player_set_gain(): + player = yup.AudioSourcePlayer() + player.setGain(0.5) + assert abs(player.getGain() - 0.5) < 1e-6 + + player.setGain(2.0) + assert abs(player.getGain() - 2.0) < 1e-6 + + player.setGain(0.0) + assert abs(player.getGain() - 0.0) < 1e-6 + + +# ============================================================================== +# AudioTransportSource +# ============================================================================== + +def test_transport_construction(): + transport = yup.AudioTransportSource() + assert transport is not None + + +def test_transport_initial_state(): + transport = yup.AudioTransportSource() + assert transport.isPlaying() is False + + +def test_transport_gain_defaults(): + transport = yup.AudioTransportSource() + assert transport.getGain() == 1.0 + + +def test_transport_set_gain(): + transport = yup.AudioTransportSource() + transport.setGain(0.75) + assert abs(transport.getGain() - 0.75) < 1e-6 + + +def test_transport_position_initial(): + transport = yup.AudioTransportSource() + assert transport.getCurrentPosition() == 0.0 + + +def test_transport_set_position(): + transport = yup.AudioTransportSource() + transport.setPosition(5.0) + + +def test_transport_length_default(): + transport = yup.AudioTransportSource() + length = transport.getLengthInSeconds() + assert length >= 0.0 + + +def test_transport_has_stream_finished(): + transport = yup.AudioTransportSource() + assert transport.hasStreamFinished() is True + + +def test_transport_set_source_none(): + transport = yup.AudioTransportSource() + transport.setSource(None) + assert transport.hasStreamFinished() is True diff --git a/python/tests/test_yup_audio_formats/__init__.py b/python/tests/test_yup_audio_formats/__init__.py new file mode 100644 index 000000000..641b47e6c --- /dev/null +++ b/python/tests/test_yup_audio_formats/__init__.py @@ -0,0 +1 @@ +import common diff --git a/python/tests/test_yup_audio_formats/test_AudioFormatManager.py b/python/tests/test_yup_audio_formats/test_AudioFormatManager.py new file mode 100644 index 000000000..20fffe5e9 --- /dev/null +++ b/python/tests/test_yup_audio_formats/test_AudioFormatManager.py @@ -0,0 +1,114 @@ +import pytest +import yup + + +# ============================================================================== +# AudioFormatType enum +# ============================================================================== + +def test_audio_format_type_enum(): + assert yup.AudioFormatType.wav is not None + assert yup.AudioFormatType.mp3 is not None + assert yup.AudioFormatType.flac is not None + assert yup.AudioFormatType.ogg is not None + assert yup.AudioFormatType.opus is not None + + +# ============================================================================== +# AudioFormatManager +# ============================================================================== + +def test_construction(): + mgr = yup.AudioFormatManager() + assert mgr is not None + + +def test_register_default_formats_all(): + mgr = yup.AudioFormatManager() + mgr.registerDefaultFormats() + # Should succeed without error + + +def test_register_default_formats_wav_only(): + mgr = yup.AudioFormatManager() + mgr.registerDefaultFormats(yup.AudioFormatType.wav) + + +def test_create_reader_for_nonexistent_file(): + mgr = yup.AudioFormatManager() + mgr.registerDefaultFormats() + result = mgr.createReaderFor(yup.File("/nonexistent/file.wav")) + assert result is None + + +def test_create_reader_for_directory(): + mgr = yup.AudioFormatManager() + mgr.registerDefaultFormats() + result = mgr.createReaderFor(yup.File("/tmp")) + assert result is None + + +# ============================================================================== +# AudioFormatReader (via AudioFormatManager) +# ============================================================================== + +@pytest.fixture +def temp_wav_file(): + import tempfile + import os + + # Minimal WAV: 44-byte header + 200 bytes of silence (100 samples, 16-bit mono) + wav = bytearray() + wav += b"RIFF" + wav += (36 + 200).to_bytes(4, "little") + wav += b"WAVE" + wav += b"fmt " + wav += (16).to_bytes(4, "little") + wav += (1).to_bytes(2, "little") # PCM + wav += (1).to_bytes(2, "little") # mono + wav += (44100).to_bytes(4, "little") # sample rate + wav += (44100 * 2).to_bytes(4, "little") # byte rate + wav += (2).to_bytes(2, "little") # block align + wav += (16).to_bytes(2, "little") # bits per sample + wav += b"data" + wav += (200).to_bytes(4, "little") + wav += b"\x00" * 200 + + fd, path = tempfile.mkstemp(suffix=".wav") + os.write(fd, wav) + os.close(fd) + yield path + os.unlink(path) + + +def test_reader_properties(temp_wav_file): + mgr = yup.AudioFormatManager() + mgr.registerDefaultFormats() + reader = mgr.createReaderFor(yup.File(temp_wav_file)) + assert reader is not None + assert reader.sampleRate == 44100.0 + assert reader.numChannels >= 1 + assert reader.bitsPerSample >= 16 + assert reader.lengthInSamples == 100 + + +def test_reader_get_format_name(temp_wav_file): + mgr = yup.AudioFormatManager() + mgr.registerDefaultFormats() + reader = mgr.createReaderFor(yup.File(temp_wav_file)) + assert reader is not None + name = reader.getFormatName() + assert isinstance(name, str) + assert len(name) > 0 + + +def test_reader_read_into_audio_buffer(temp_wav_file): + mgr = yup.AudioFormatManager() + mgr.registerDefaultFormats() + reader = mgr.createReaderFor(yup.File(temp_wav_file)) + assert reader is not None + + buffer = yup.AudioBuffer(reader.numChannels, 50) + ok = reader.read(buffer, 0, 50, 0, True, True) + # Silly parser allows read of all-zero data + assert ok is True diff --git a/python/tests/test_yup_graphics/test_Rhi.py b/python/tests/test_yup_graphics/test_Rhi.py new file mode 100644 index 000000000..2f97ace1b --- /dev/null +++ b/python/tests/test_yup_graphics/test_Rhi.py @@ -0,0 +1,193 @@ +import pytest +import yup + + +# ============================================================================== +# GPU Enums +# ============================================================================== + +def test_gpu_shader_language_enum(): + assert yup.GpuShaderLanguage.wgsl is not None + assert yup.GpuShaderLanguage.glsl is not None + assert yup.GpuShaderLanguage.msl is not None + assert yup.GpuShaderLanguage.hlsl is not None + + +def test_gpu_vertex_format_enum(): + assert yup.GpuVertexFormat.float1 is not None + assert yup.GpuVertexFormat.float2 is not None + assert yup.GpuVertexFormat.float3 is not None + assert yup.GpuVertexFormat.float4 is not None + + +def test_gpu_vertex_step_mode_enum(): + assert yup.GpuVertexStepMode.vertex is not None + assert yup.GpuVertexStepMode.instance is not None + + +def test_gpu_primitive_topology_enum(): + assert yup.GpuPrimitiveTopology.pointList is not None + assert yup.GpuPrimitiveTopology.lineList is not None + assert yup.GpuPrimitiveTopology.triangleList is not None + assert yup.GpuPrimitiveTopology.triangleStrip is not None + + +def test_gpu_index_format_enum(): + assert yup.GpuIndexFormat.none is not None + assert yup.GpuIndexFormat.uint16 is not None + assert yup.GpuIndexFormat.uint32 is not None + + +def test_gpu_cull_mode_enum(): + assert yup.GpuCullMode.none is not None + assert yup.GpuCullMode.front is not None + assert yup.GpuCullMode.back is not None + + +def test_gpu_face_winding_enum(): + assert yup.GpuFaceWinding.clockwise is not None + assert yup.GpuFaceWinding.counterClockwise is not None + + +def test_gpu_compare_function_enum(): + assert yup.GpuCompareFunction.never is not None + assert yup.GpuCompareFunction.less is not None + assert yup.GpuCompareFunction.equal is not None + assert yup.GpuCompareFunction.always is not None + + +def test_gpu_stencil_op_enum(): + assert yup.GpuStencilOp.keep is not None + assert yup.GpuStencilOp.zero is not None + assert yup.GpuStencilOp.replace is not None + + +def test_gpu_blend_factor_enum(): + assert yup.GpuBlendFactor.zero is not None + assert yup.GpuBlendFactor.one is not None + assert yup.GpuBlendFactor.srcAlpha is not None + assert yup.GpuBlendFactor.oneMinusSrcAlpha is not None + + +def test_gpu_blend_op_enum(): + assert yup.GpuBlendOp.add is not None + assert yup.GpuBlendOp.subtract is not None + assert yup.GpuBlendOp.min is not None + assert yup.GpuBlendOp.max is not None + + +def test_gpu_texture_format_enum(): + assert yup.GpuTextureFormat.rgba8unorm is not None + assert yup.GpuTextureFormat.bgra8unorm is not None + assert yup.GpuTextureFormat.rgba16float is not None + + +def test_gpu_buffer_type_enum(): + assert yup.GpuBufferType.vertex is not None + assert yup.GpuBufferType.index is not None + assert yup.GpuBufferType.uniform is not None + + +def test_graphics_api_enum(): + assert yup.GraphicsApi.Headless is not None + assert yup.GraphicsApi.OpenGL is not None + assert yup.GraphicsApi.Metal is not None + assert yup.GraphicsApi.Direct3D is not None + assert yup.GraphicsApi.WebGPU is not None + + +# ============================================================================== +# GPU Config Structs +# ============================================================================== + +def test_gpu_shader_source_defaults(): + src = yup.GpuShaderSource() + assert src.language == yup.GpuShaderLanguage.wgsl + assert src.entryPoint is None + + +def test_gpu_shader_source_set_fields(): + src = yup.GpuShaderSource() + src.language = yup.GpuShaderLanguage.glsl + src.codeSize = 1024 + assert src.language == yup.GpuShaderLanguage.glsl + assert src.codeSize == 1024 + + +def test_gpu_vertex_attribute_construction(): + attr = yup.GpuVertexAttribute() + assert attr.format == yup.GpuVertexFormat.float4 + assert attr.offset == 0 + assert attr.shaderLocation == 0 + + +def test_gpu_vertex_attribute_with_args(): + attr = yup.GpuVertexAttribute( + yup.GpuVertexFormat.float3, 12, 0 + ) + assert attr.format == yup.GpuVertexFormat.float3 + assert attr.offset == 12 + assert attr.shaderLocation == 0 + + +def test_gpu_vertex_buffer_layout_defaults(): + layout = yup.GpuVertexBufferLayout() + assert layout.stride == 0 + assert layout.stepMode == yup.GpuVertexStepMode.vertex + assert layout.attributeCount == 0 + + +def test_gpu_blend_state_defaults(): + bs = yup.GpuBlendState() + assert bs.srcColor == yup.GpuBlendFactor.srcAlpha + assert bs.dstColor == yup.GpuBlendFactor.oneMinusSrcAlpha + assert bs.colorOp == yup.GpuBlendOp.add + + +def test_gpu_color_target_defaults(): + ct = yup.GpuColorTarget() + assert ct.format == yup.GpuTextureFormat.rgba8unorm + assert ct.blendEnabled is True + + +def test_gpu_stencil_face_state_defaults(): + sfs = yup.GpuStencilFaceState() + assert sfs.compare == yup.GpuCompareFunction.always + assert sfs.failOp == yup.GpuStencilOp.keep + assert sfs.depthFailOp == yup.GpuStencilOp.keep + assert sfs.passOp == yup.GpuStencilOp.keep + + +def test_gpu_depth_stencil_state_defaults(): + dss = yup.GpuDepthStencilState() + assert dss.enabled is False + assert dss.depthWriteEnabled is True + assert dss.depthCompare == yup.GpuCompareFunction.less + + +def test_gpu_pipeline_options_defaults(): + opts = yup.GpuPipelineOptions() + assert opts.topology == yup.GpuPrimitiveTopology.triangleList + assert opts.indexFormat == yup.GpuIndexFormat.none + assert opts.cullMode == yup.GpuCullMode.none + assert opts.colorTargetCount == 0 + assert opts.sampleCount == 1 + + +def test_gpu_render_options_defaults(): + opts = yup.GpuRenderOptions() + assert opts.clear is True + assert opts.clearColor is not None + + +def test_gpu_render_options_with_args(): + opts = yup.GpuRenderOptions(True, yup.Colors.black) + assert opts.clear is True + assert opts.clearColor == yup.Colors.black + + +def test_graphics_context_options_defaults(): + opts = yup.GraphicsContextOptions() + assert opts.retinaDisplay is True + assert opts.readableFramebuffer is False + assert opts.synchronousShaderCompilations is False diff --git a/python/tests/test_yup_gui/__init__.py b/python/tests/test_yup_gui/__init__.py new file mode 100644 index 000000000..641b47e6c --- /dev/null +++ b/python/tests/test_yup_gui/__init__.py @@ -0,0 +1 @@ +import common diff --git a/python/tests/test_yup_gui/test_Button.py b/python/tests/test_yup_gui/test_Button.py new file mode 100644 index 000000000..04312bb77 --- /dev/null +++ b/python/tests/test_yup_gui/test_Button.py @@ -0,0 +1,21 @@ +import yup + + +# ============================================================================== +# Button +# ============================================================================== + +def test_button_construction(): + btn = yup.Button() + assert btn is not None + + +def test_button_initial_state(): + btn = yup.Button() + assert btn.isButtonOver() is False + assert btn.isButtonDown() is False + + +def test_button_onclick_default(): + btn = yup.Button() + assert btn.onClick is None diff --git a/python/tests/test_yup_gui/test_FlexBox.py b/python/tests/test_yup_gui/test_FlexBox.py new file mode 100644 index 000000000..4bed2170b --- /dev/null +++ b/python/tests/test_yup_gui/test_FlexBox.py @@ -0,0 +1,69 @@ +import pytest +import yup + + +# ============================================================================== +# FlexItem (value object — no app needed) +# ============================================================================== + +def test_flex_item_default(): + item = yup.FlexItem() + assert item is not None + assert item.flexGrow == 0.0 + assert item.flexShrink == 1.0 + + +def test_flex_item_with_dimensions(): + item = yup.FlexItem(100.0, 50.0) + assert item.width == 100.0 + assert item.height == 50.0 + + +def test_flex_item_with_flex(): + item = yup.FlexItem().withFlex(2.0) + assert item.flexGrow == 2.0 + + +def test_flex_item_with_margin(): + item = yup.FlexItem().withMargin(8.0) + assert item.marginLeft == 8.0 + assert item.marginRight == 8.0 + + +def test_flex_item_with_order(): + item = yup.FlexItem().withOrder(5) + assert item.order == 5 + + +def test_flex_alignment_enum(): + assert yup.FlexAlignSelf.autoAlign is not None + assert yup.FlexAlignSelf.center is not None + assert yup.FlexAlignSelf.stretch is not None + + +# ============================================================================== +# FlexBox (value object — no app needed for empty layout) +# ============================================================================== + +def test_flex_box_default(): + box = yup.FlexBox() + assert box is not None + assert box.flexDirection == yup.FlexDirection.row + + +def test_flex_box_full_constructor(): + box = yup.FlexBox( + yup.FlexDirection.column, + yup.FlexWrap.wrap, + yup.FlexAlignItems.center, + yup.FlexJustifyContent.spaceBetween, + yup.FlexAlignContent.stretch, + ) + assert box.flexDirection == yup.FlexDirection.column + assert box.flexWrap == yup.FlexWrap.wrap + + +def test_flex_box_empty_layout(): + box = yup.FlexBox() + box.performLayout(yup.Rectangle[int](0, 0, 200, 200)) + diff --git a/python/tests/test_yup_gui/test_Grid.py b/python/tests/test_yup_gui/test_Grid.py new file mode 100644 index 000000000..1ffce15d7 --- /dev/null +++ b/python/tests/test_yup_gui/test_Grid.py @@ -0,0 +1,64 @@ +import pytest +import yup + + +# ============================================================================== +# GridItem (value object — no app needed) +# ============================================================================== + +def test_grid_item_default(): + item = yup.GridItem() + assert item is not None + assert item.column == 0 + assert item.row == 0 + assert item.columnSpan == 1 + assert item.rowSpan == 1 + + +def test_grid_item_with_column(): + item = yup.GridItem().withColumn(3) + assert item.column == 3 + + +def test_grid_item_with_row(): + item = yup.GridItem().withRow(2) + assert item.row == 2 + + +def test_grid_item_with_span(): + item = yup.GridItem().withColumnSpan(2).withRowSpan(3) + assert item.columnSpan == 2 + assert item.rowSpan == 3 + + +def test_grid_item_with_margin(): + item = yup.GridItem().withMargin(6.0) + assert item.marginLeft == 6.0 + + +# ============================================================================== +# Grid (value object — no app needed for empty layout) +# ============================================================================== + +def test_grid_default(): + grid = yup.Grid() + assert grid is not None + assert grid.autoRows == 40.0 + assert grid.autoColumns == 100.0 + + +def test_grid_track_info(): + px = yup.TrackInfo.px(120.0) + assert px.pixelSize == 120.0 + + fr = yup.TrackInfo.fr(2.0) + assert fr.fraction == 2.0 + + auto = yup.TrackInfo.auto_() + assert auto.isAuto is True + + +def test_grid_empty_layout(): + grid = yup.Grid() + grid.performLayout(yup.Rectangle[int](0, 0, 300, 200)) + From d7040949f66579ac293456ecf70544186137826e Mon Sep 17 00:00:00 2001 From: kunitoki Date: Mon, 27 Jul 2026 00:04:14 +0200 Subject: [PATCH 2/2] Fix warnings --- tests/yup_python/yup_ScriptPython.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/yup_python/yup_ScriptPython.cpp b/tests/yup_python/yup_ScriptPython.cpp index 186f0d4ca..205c0840c 100644 --- a/tests/yup_python/yup_ScriptPython.cpp +++ b/tests/yup_python/yup_ScriptPython.cpp @@ -100,22 +100,28 @@ TEST_F (ScriptPythonTest, RunPythonTests) import runpy import sys - sys.path.append('{{root_path}}') sys.path.append('{{root_path}}/lib/python{{version}}/site-packages') + sys.path.append('{{root_path}}') package = 'pytest' try: import pytest except ImportError: + # Temporarily remove target from sys.path to avoid pip's + # "Unexpected import after pip install started" deprecation warnings + sys.path.remove('{{root_path}}') + old_argv = [x for x in sys.argv] - sys.argv = ['pip', 'install', 'pytest', '--target', '{{root_path}}'] + sys.argv = ['pip', 'install', '--upgrade', 'pytest', '--target', '{{root_path}}'] try: runpy.run_module('pip', run_name='__main__') except SystemExit as ex: print(str(ex)) finally: sys.argv = old_argv + + sys.path.append('{{root_path}}') import pytest assert pytest.main(['-x', '{{test_path}}', '-vvv', '-p', 'no:cacheprovider']) == 0