fox_utils 0.1.7: clipboard, drag & drop, Android file saving
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dc16bba054
46 files changed
+4738
No files matched your search
Executable
+31
@@ -0,0 +1,31 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
@@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: package
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
## 0.1.7
|
||||
|
||||
* Add `DesktopDropX` + `DropTarget` — OS-level drag & drop of files and
|
||||
images into the app, powered by a vendored desktop_drop engine (v0.7.1,
|
||||
Apache-2.0): GTK uri-list drag destination on Linux, OLE `IDropTarget`
|
||||
(`CF_HDROP`) on Windows, dead code on mobile and web. `DesktopDropX`
|
||||
provides `pathsOf` (dropped file paths) and `readImage` (magic-byte
|
||||
image sniffing, same table as `ClipboardX.pasteImage`).
|
||||
* The Linux/Windows plugin entry is now a combined `FoxUtilsPlugin`
|
||||
registration that wires the vendored pasteboard and desktop_drop engines
|
||||
from one native entry per platform (the registered plugin class changed
|
||||
from `PasteboardPlugin` to `FoxUtilsPlugin` — generated registrants
|
||||
regenerate on the next build).
|
||||
* New dependencies: `cross_file` (drop items are `XFile`s) and
|
||||
`universal_platform` (web-safe platform checks), both required by the
|
||||
vendored engine.
|
||||
|
||||
## 0.1.6
|
||||
|
||||
* Add `ClipboardX.pasteImage` — reads an image from the clipboard whether it
|
||||
was copied as image data (browser) or as a file reference (file manager),
|
||||
on Windows, Linux and Android. Adds a `readFileBytes` channel method for
|
||||
resolving Android content URIs.
|
||||
|
||||
## 0.1.5
|
||||
|
||||
* Add `ClipboardX` — clipboard image/files/text copy via a vendored
|
||||
pasteboard engine (MixinNetwork, Apache-2.0) with modernized builds:
|
||||
the Android channel is Java (no KGP), the FileProvider is merged from
|
||||
the plugin manifest (zero per-project setup), image writes are byte-faithful
|
||||
with magic-byte MIME sniffing, and two upstream bugs are fixed.
|
||||
|
||||
## 0.1.4
|
||||
|
||||
* Add `AndroidFiles.save` — permission-free file saving to public Android
|
||||
directories (Pictures/Movies/Music/Download) with an optional media scan.
|
||||
Android platform channel, no storage permissions required.
|
||||
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -0,0 +1,183 @@
|
||||
# fox_utils
|
||||
|
||||
Reusable Flutter utilities shared across projects: permission-free Android
|
||||
file saving, a cross-platform clipboard wrapper, and desktop drag & drop.
|
||||
|
||||
## Features
|
||||
|
||||
### `AndroidFiles.save` — save files to public Android storage
|
||||
|
||||
Writes any bytes to a public Android directory (`Pictures`, `Movies`,
|
||||
`Music`, `Download`) **without any storage permission**, with an optional
|
||||
media scan so the file shows up in gallery apps.
|
||||
|
||||
```dart
|
||||
final path = await AndroidFiles.save(
|
||||
bytes, // any file type
|
||||
fileName: 'image.jpg',
|
||||
location: AndroidPublicLocation.pictures, // pictures | movies | music | downloads
|
||||
subPath: 'Imagen', // folder structure under the location
|
||||
scan: true, // false = saved but not indexed (no gallery)
|
||||
);
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Requires **no permissions** on Android 10+ (API 29+): creating new files
|
||||
inside media collections is permission-free. Legacy devices (< API 29) may
|
||||
fail — the exception carries the native message.
|
||||
- `scan: false` writes the file without indexing it. On modern Android the
|
||||
file can still be picked up by a later system rescan; for a hard
|
||||
"never in gallery" guarantee, save outside a media collection or add a
|
||||
`.nomedia` file to the folder.
|
||||
- Throws `FileSaveException` on failure (logged with stack trace).
|
||||
|
||||
### `ClipboardX` — clipboard copy
|
||||
|
||||
```dart
|
||||
await ClipboardX.copyImage(bytes); // image bytes as-is, MIME sniffed
|
||||
await ClipboardX.copyFiles(['/path/a.png']); // desktop: files
|
||||
ClipboardX.copyText('hello'); // text (Flutter built-in)
|
||||
final text = await ClipboardX.readText();
|
||||
final image = await ClipboardX.readImage();
|
||||
final files = await ClipboardX.readFiles();
|
||||
```
|
||||
|
||||
Platform matrix:
|
||||
|
||||
| Operation | Android | Windows | Linux | macOS | iOS |
|
||||
|---|---|---|---|---|---|
|
||||
| copy image | ✓ (FileProvider) | ✓ (GDI+) | ✓ (file reference) | ✓ | ✓ |
|
||||
| copy files | – | ✓ (CF_HDROP) | ✓ (GNOME) | ✓ | – |
|
||||
| copy/read text | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| read image | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| read files | ✓ (URIs) | ✓ | ✓ | ✓ | – |
|
||||
|
||||
Notes:
|
||||
|
||||
- Image bytes are placed **as-is** (no re-encode); the extension is sniffed
|
||||
from magic bytes (PNG/JPEG/GIF/WebP/BMP), so animated GIFs survive.
|
||||
- **Linux cannot hold raw image bytes** on the clipboard (GTK limitation), so
|
||||
`copyImage` stages the bytes to a rotating temp file and copies it as a
|
||||
file reference — pasted apps receive the image file. The staging file is
|
||||
replaced on each copy.
|
||||
- **Android requires no manifest setup**: the FileProvider is declared in
|
||||
this plugin's manifest and merged automatically (`${applicationId}.provider`).
|
||||
Images are staged in the app cache dir.
|
||||
- Throws `ClipboardCopyException` on failure.
|
||||
|
||||
### `ClipboardX.pasteImage` — paste an image, whatever the source
|
||||
|
||||
```dart
|
||||
final Uint8List? image = await ClipboardX.pasteImage();
|
||||
if (image != null) {
|
||||
// send to server, display, whatever
|
||||
}
|
||||
```
|
||||
|
||||
Resolution order: image data on the clipboard first (browser "copy image"),
|
||||
then the first file reference (file manager "copy"). The file's first bytes
|
||||
are sniffed (PNG/JPEG/GIF/WebP/BMP) before the full read, so huge non-image
|
||||
files are never loaded. Returns `null` when the clipboard holds no image.
|
||||
|
||||
Source matrix:
|
||||
|
||||
| Copied from | Windows | Linux | Android |
|
||||
|---|---|---|---|
|
||||
| Browser, "copy image" | CF_DIB → image bytes | `image/png` → image bytes | content URI (image mime) → image bytes |
|
||||
| File manager, "copy" | CF_HDROP → first file's bytes | `text/uri-list` → first file's bytes | content URI → resolved bytes |
|
||||
|
||||
Android notes:
|
||||
|
||||
- Reading the clipboard works **only while the app is in the foreground**
|
||||
(Android 10+); background reads return `null`.
|
||||
- Android 12+ shows a system toast ("app pasted from your clipboard") on
|
||||
read — cosmetic.
|
||||
- File references come back as `content://` URIs; `pasteImage` resolves them
|
||||
through the platform channel (`readFileBytes`), so no manifest setup or
|
||||
permission is needed.
|
||||
|
||||
### `DesktopDropX` + `DropTarget` — drag & drop files and images
|
||||
|
||||
OS-level drag & drop into the app (desktop only). Wrap any widget in
|
||||
`DropTarget` to get drag enter/exit/update/drop callbacks; `DesktopDropX`
|
||||
turns the dropped items into plain paths or image bytes:
|
||||
|
||||
```dart
|
||||
DropTarget(
|
||||
onDragEntered: (d) => setState(() => dragging = true),
|
||||
onDragExited: (d) => setState(() => dragging = false),
|
||||
onDragDone: (details) async {
|
||||
final paths = DesktopDropX.pathsOf(details.files); // absolute paths
|
||||
final Uint8List? image = await DesktopDropX.readImage(details.files);
|
||||
// image == null when the drop contains no readable image
|
||||
},
|
||||
child: myWidget,
|
||||
)
|
||||
```
|
||||
|
||||
Platform notes:
|
||||
|
||||
- **Linux**: GTK drag destination on the whole window (`text/uri-list` from
|
||||
GNOME/KDE file managers, browsers, …). Items are absolute paths, delivered
|
||||
in the order dropped.
|
||||
- **Windows**: OLE `IDropTarget` on the window (`CF_HDROP` from Explorer,
|
||||
browsers, …). Same path semantics; `DropDoneDetails.localPosition` is
|
||||
already DPI-corrected.
|
||||
- **Android/iOS/web**: no native handler — the widget renders its child and
|
||||
no drop events ever arrive. **macOS** is not vendored (no macOS target);
|
||||
re-adding it is a copy of upstream's `macos/` folder behind the same API.
|
||||
- `DropItem` extends `XFile`: `item.path`, `item.name`, `item.readAsBytes()`,
|
||||
`item.openRead()`, … are available directly.
|
||||
- Drop targets receive events for the **whole window**; an invisible
|
||||
`DropTarget` keeps receiving them, so set `enable: false` when pushing a
|
||||
page over it. Coordinate-based `DropTarget` bounds filtering is handled by
|
||||
the widget.
|
||||
- `readImage` never throws: unreadable files are logged with a stack trace
|
||||
and skipped; `null` means "no image in this drop".
|
||||
|
||||
## Vendored engines
|
||||
|
||||
The clipboard channels (`pasteboard` method channel) are **vendored from
|
||||
MixinNetwork's `pasteboard` package (v0.5.0, Apache-2.0)** because upstream's
|
||||
build applies the legacy Kotlin Gradle Plugin (KGP), which future Flutter
|
||||
versions will reject. The vendored copy lives in `lib/vendor/` (Dart) and the
|
||||
native platform folders, with these changes:
|
||||
|
||||
- **Android**: the Kotlin plugin was ported to Java and merged into
|
||||
`FoxUtilsPlugin` (one plugin class, no KGP, no Kotlin in the module).
|
||||
- **Byte-faithful image writes**: upstream decodes and re-encodes images as
|
||||
PNG (destroying GIF animation and metadata); ours writes the original bytes
|
||||
with magic-byte MIME sniffing.
|
||||
- **Merged FileProvider**: declared in the plugin manifest — zero per-project
|
||||
setup (upstream requires a manual manifest entry per app).
|
||||
- **Two upstream bugs fixed**: clipboard image reads ran synchronously on the
|
||||
calling thread (now on the executor), and `writeFiles` dropped the first
|
||||
clip item (now assembles correctly).
|
||||
- **Linux/Windows**: C++ sources byte-identical, include paths and CMake
|
||||
targets renamed to the `fox_utils` package name.
|
||||
|
||||
To diff against upstream: `git diff` the folders against
|
||||
`packages/pasteboard/` at tag `v0.5.0` of
|
||||
`https://github.com/MixinNetwork/flutter-plugins`. If upstream ever
|
||||
modernizes their build, un-vendoring is a swap behind the same `ClipboardX`
|
||||
API.
|
||||
|
||||
The drag & drop channels (`desktop_drop` method channel) are **vendored from
|
||||
MixinNetwork's `desktop_drop` package (v0.7.1, Apache-2.0)** for the same
|
||||
reason: its `android/` folder applies KGP. Only the Dart sources and the
|
||||
Linux/Windows native folders are vendored — the Android/Kotlin, macOS and
|
||||
web bindings are omitted (Android DnD is out of scope, and there is no macOS
|
||||
target in the app). Linux/Windows native sources are byte-identical to
|
||||
upstream except the include path rename (`include/fox_utils/`). Both engines
|
||||
are registered from one native entry per platform:
|
||||
`fox_utils_plugin_register_with_registrar` (Linux) /
|
||||
`FoxUtilsPluginRegisterWithRegistrar` (Windows) — the plugin class in
|
||||
`pubspec.yaml` is no longer `PasteboardPlugin`. To diff against upstream, use
|
||||
tag `desktop_drop-v0.7.1` of the same monorepo; un-vendoring is a swap
|
||||
behind the same `DesktopDropX` API.
|
||||
|
||||
## License
|
||||
|
||||
MIT for this package's own code; the vendored engines are Apache-2.0
|
||||
© MixinNetwork (see `lib/vendor/` and the native platform folders).
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,9 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/workspace.xml
|
||||
/.idea/libraries
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.cxx
|
||||
@@ -0,0 +1,46 @@
|
||||
group = "com.foxyz.fox_utils"
|
||||
version = "0.1.4"
|
||||
|
||||
buildscript {
|
||||
val kotlinVersion = "2.4.0"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:9.1.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.foxyz.fox_utils"
|
||||
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// FileProvider for clipboard image sharing (pasteboard channel).
|
||||
implementation("androidx.core:core:1.16.0")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "fox_utils"
|
||||
@@ -0,0 +1,17 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.foxyz.fox_utils">
|
||||
<application>
|
||||
<!-- Serves clipboard images to other apps (pasteboard clipboard engine).
|
||||
Authority matches what the plugin computes at runtime:
|
||||
context.getPackageName() + ".provider". -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,306 @@
|
||||
package com.foxyz.fox_utils;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.net.Uri;
|
||||
import android.os.Environment;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin;
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
|
||||
import io.flutter.plugin.common.MethodChannel.Result;
|
||||
|
||||
/**
|
||||
* Fox utils Android plugin.
|
||||
*
|
||||
* <p>Two channels:
|
||||
* <ul>
|
||||
* <li>{@code fox_utils/android_files} — permission-free saving to public
|
||||
* directories (see AndroidFiles.save).</li>
|
||||
* <li>{@code pasteboard} — clipboard image/files read/write, ported from
|
||||
* one.mixin.pasteboard (Apache-2.0) with fixes: byte-faithful image
|
||||
* writes (no Bitmap re-encode), correct async executor use, and a
|
||||
* working writeFiles clip assembly.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class FoxUtilsPlugin implements FlutterPlugin, MethodCallHandler {
|
||||
private static final String FILES_CHANNEL = "fox_utils/android_files";
|
||||
private static final String CLIPBOARD_CHANNEL = "pasteboard";
|
||||
private static final String CLIPBOARD_X_CHANNEL = "fox_utils/clipboard";
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private Context context;
|
||||
|
||||
@Override
|
||||
public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) {
|
||||
context = binding.getApplicationContext();
|
||||
new MethodChannel(binding.getBinaryMessenger(), FILES_CHANNEL).setMethodCallHandler(this);
|
||||
new MethodChannel(binding.getBinaryMessenger(), CLIPBOARD_CHANNEL).setMethodCallHandler(this);
|
||||
new MethodChannel(binding.getBinaryMessenger(), CLIPBOARD_X_CHANNEL).setMethodCallHandler(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
|
||||
switch (call.method) {
|
||||
// --- fox_utils/android_files ---
|
||||
case "saveFile":
|
||||
saveFile(call, result);
|
||||
break;
|
||||
// --- pasteboard (vendored) ---
|
||||
case "image":
|
||||
readImage(result);
|
||||
break;
|
||||
case "files":
|
||||
readFiles(result);
|
||||
break;
|
||||
case "html":
|
||||
readHtml(result);
|
||||
break;
|
||||
case "writeFiles":
|
||||
writeFiles(call, result);
|
||||
break;
|
||||
case "writeImage":
|
||||
writeImage(call, result);
|
||||
break;
|
||||
// --- fox_utils/clipboard ---
|
||||
case "readFileBytes":
|
||||
readFileBytes(call, result);
|
||||
break;
|
||||
default:
|
||||
result.notImplemented();
|
||||
}
|
||||
}
|
||||
|
||||
// --- fox_utils/android_files ---
|
||||
|
||||
private void saveFile(MethodCall call, Result result) {
|
||||
final byte[] bytes = call.argument("bytes");
|
||||
final String fileName = call.argument("fileName");
|
||||
final String location = call.argument("location");
|
||||
final String subPath = call.argument("subPath");
|
||||
final Boolean scan = call.argument("scan");
|
||||
if (bytes == null || fileName == null || location == null || subPath == null || scan == null) {
|
||||
result.error("INVALID_ARGUMENTS", "saveFile requires bytes, fileName, location, subPath and scan.", null);
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
result.success(saveFileSync(bytes, fileName, location, subPath, scan));
|
||||
} catch (Exception e) {
|
||||
result.error("SAVE_FAILED", e.getMessage(), null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation") // Still works permission-free on modern Android.
|
||||
private String saveFileSync(byte[] bytes, String fileName, String location, String subPath, boolean scan)
|
||||
throws IOException {
|
||||
File base = new File(Environment.getExternalStoragePublicDirectory(directoryFor(location)), subPath);
|
||||
if (!base.exists() && !base.mkdirs()) {
|
||||
throw new IOException("Could not create directory " + base);
|
||||
}
|
||||
File file = new File(base, fileName);
|
||||
try (FileOutputStream out = new FileOutputStream(file)) {
|
||||
out.write(bytes);
|
||||
}
|
||||
if (scan) {
|
||||
MediaScannerConnection.scanFile(context, new String[]{file.getAbsolutePath()}, null, null);
|
||||
}
|
||||
return file.getAbsolutePath();
|
||||
}
|
||||
|
||||
private static String directoryFor(String location) {
|
||||
switch (location) {
|
||||
case "movies":
|
||||
return Environment.DIRECTORY_MOVIES;
|
||||
case "music":
|
||||
return Environment.DIRECTORY_MUSIC;
|
||||
case "downloads":
|
||||
return Environment.DIRECTORY_DOWNLOADS;
|
||||
case "pictures":
|
||||
default:
|
||||
return Environment.DIRECTORY_PICTURES;
|
||||
}
|
||||
}
|
||||
|
||||
// --- pasteboard channel (vendored from one.mixin.pasteboard, Apache-2.0) ---
|
||||
|
||||
private ClipboardManager clipboard() {
|
||||
return (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
}
|
||||
|
||||
private void readImage(Result result) {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
ClipData.Item first = firstClipItem();
|
||||
if (first == null || first.getUri() == null) {
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
Uri uri = first.getUri();
|
||||
String mime = context.getContentResolver().getType(uri);
|
||||
if (mime == null || !mime.startsWith("image")) {
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
try (InputStream in = context.getContentResolver().openInputStream(uri)) {
|
||||
if (in == null) {
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
result.success(readAll(in));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.error("Error", "Failed to read image", e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void readFiles(Result result) {
|
||||
ClipData clip = clipboard().getPrimaryClip();
|
||||
if (clip == null || clip.getItemCount() == 0) {
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
List<String> files = new ArrayList<>();
|
||||
for (int i = 0; i < clip.getItemCount(); i++) {
|
||||
Uri uri = clip.getItemAt(i).getUri();
|
||||
if (uri != null) {
|
||||
files.add(uri.toString());
|
||||
}
|
||||
}
|
||||
result.success(files);
|
||||
}
|
||||
|
||||
private void readHtml(Result result) {
|
||||
ClipData.Item first = firstClipItem();
|
||||
result.success(first == null ? null : first.getHtmlText());
|
||||
}
|
||||
|
||||
private void writeFiles(MethodCall call, Result result) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> args = call.arguments();
|
||||
if (args == null) {
|
||||
result.error("NoArgs", "Missing Arguments", null);
|
||||
return;
|
||||
}
|
||||
ClipboardManager manager = clipboard();
|
||||
ClipData clip = null;
|
||||
for (String path : args) {
|
||||
Uri uri = Uri.parse(path);
|
||||
if (clip == null) {
|
||||
clip = ClipData.newUri(context.getContentResolver(), "files", uri);
|
||||
} else {
|
||||
clip.addItem(new ClipData.Item(uri));
|
||||
}
|
||||
}
|
||||
if (clip != null) {
|
||||
manager.setPrimaryClip(clip);
|
||||
}
|
||||
result.success(null);
|
||||
}
|
||||
|
||||
private void writeImage(MethodCall call, Result result) {
|
||||
byte[] image = call.arguments();
|
||||
if (image == null) {
|
||||
result.error("NoArgs", "Missing Arguments", null);
|
||||
return;
|
||||
}
|
||||
// Bytes are staged as-is (no re-encode); the extension is sniffed from
|
||||
// the magic bytes so receivers get the correct MIME type — animated
|
||||
// GIFs and WebP survive copy/paste this way.
|
||||
final File file = new File(context.getCacheDir(),
|
||||
UUID.randomUUID().toString() + extensionFor(image));
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
try (FileOutputStream out = new FileOutputStream(file)) {
|
||||
out.write(image);
|
||||
}
|
||||
Uri uri = FileProvider.getUriForFile(context, context.getPackageName() + ".provider", file);
|
||||
clipboard().setPrimaryClip(ClipData.newUri(context.getContentResolver(), "image", uri));
|
||||
result.success(null);
|
||||
} catch (Exception e) {
|
||||
result.error("Error", "Failed to write image", e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ClipData.Item firstClipItem() {
|
||||
ClipData clip = clipboard().getPrimaryClip();
|
||||
return clip == null || clip.getItemCount() == 0 ? null : clip.getItemAt(0);
|
||||
}
|
||||
|
||||
/** Resolves a content URI (e.g. a file manager's clipboard entry) to bytes. */
|
||||
private void readFileBytes(MethodCall call, Result result) {
|
||||
String uri = call.arguments();
|
||||
if (uri == null) {
|
||||
result.error("NoArgs", "Missing Arguments", null);
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try (InputStream in = context.getContentResolver().openInputStream(Uri.parse(uri))) {
|
||||
if (in == null) {
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
result.success(readAll(in));
|
||||
} catch (Exception e) {
|
||||
result.error("Error", "Failed to read file", e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Sniffs common image magic bytes so the pasted file carries the right MIME type. */
|
||||
private static String extensionFor(byte[] b) {
|
||||
if (b.length >= 3 && (b[0] & 0xFF) == 0xFF && (b[1] & 0xFF) == 0xD8 && (b[2] & 0xFF) == 0xFF) {
|
||||
return ".jpg";
|
||||
}
|
||||
if (b.length >= 8 && (b[0] & 0xFF) == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) {
|
||||
return ".png";
|
||||
}
|
||||
if (b.length >= 6 && (b[0] & 0xFF) == 0x47 && b[1] == 0x49 && b[2] == 0x46 && b[3] == 0x38) {
|
||||
return ".gif";
|
||||
}
|
||||
if (b.length >= 12 && b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F'
|
||||
&& b[4] == 'W' && b[5] == 'E' && b[6] == 'B' && b[7] == 'P') {
|
||||
return ".webp";
|
||||
}
|
||||
if (b.length >= 2 && b[0] == 'B' && b[1] == 'M') {
|
||||
return ".bmp";
|
||||
}
|
||||
return ".png";
|
||||
}
|
||||
|
||||
private static byte[] readAll(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
|
||||
context = null;
|
||||
executor.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Clipboard images are staged in the app cache dir. -->
|
||||
<cache-path name="cache" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// A public Android storage location that needs no write permission.
|
||||
enum AndroidPublicLocation {
|
||||
pictures,
|
||||
movies,
|
||||
music,
|
||||
downloads,
|
||||
}
|
||||
|
||||
/// Thrown when saving a file via [AndroidFiles.save] fails.
|
||||
class FileSaveException implements Exception {
|
||||
FileSaveException(this.message, {this.details});
|
||||
|
||||
final String message;
|
||||
final String? details;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'FileSaveException: $message${details != null ? ' — $details' : ''}';
|
||||
}
|
||||
|
||||
/// Saves files to public Android directories without any storage permission.
|
||||
///
|
||||
/// Files are written with plain file I/O, so on Android 10+ they are NOT
|
||||
/// indexed by the media store. Pass [scan] = true to run the media scanner
|
||||
/// and make the file show up in gallery apps.
|
||||
class AndroidFiles {
|
||||
AndroidFiles._();
|
||||
|
||||
static const MethodChannel _channel = MethodChannel('fox_utils/android_files');
|
||||
|
||||
/// Writes [bytes] to `<location>/<subPath>/<fileName>` on shared storage and
|
||||
/// returns the absolute path. Requires no storage permission on Android 10+.
|
||||
///
|
||||
/// When [scan] is true the file is indexed into the media store (gallery
|
||||
/// visibility); when false it stays on disk without being indexed.
|
||||
///
|
||||
/// Throws [FileSaveException] on failure (invalid arguments, I/O error, or
|
||||
/// a platform rejection — e.g. pre-Android-10 devices without legacy
|
||||
/// storage access). The native error message is carried in the exception.
|
||||
static Future<String> save(
|
||||
Uint8List bytes, {
|
||||
required String fileName,
|
||||
AndroidPublicLocation location = AndroidPublicLocation.pictures,
|
||||
String subPath = '',
|
||||
bool scan = true,
|
||||
}) async {
|
||||
try {
|
||||
final path = await _channel.invokeMethod<String>('saveFile', {
|
||||
'bytes': bytes,
|
||||
'fileName': fileName,
|
||||
'location': location.name,
|
||||
'subPath': subPath,
|
||||
'scan': scan,
|
||||
});
|
||||
if (path == null) {
|
||||
throw FileSaveException('saveFile returned no path.');
|
||||
}
|
||||
return path;
|
||||
} on PlatformException catch (e, st) {
|
||||
debugPrint('AndroidFiles.save failed (${e.code}): ${e.message}\n$st');
|
||||
throw FileSaveException(e.message ?? 'Failed to save file.', details: e.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:io' show Directory, File, Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'vendor/pasteboard.dart';
|
||||
|
||||
/// Thrown when a clipboard copy fails.
|
||||
class ClipboardCopyException implements Exception {
|
||||
ClipboardCopyException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ClipboardCopyException: $message';
|
||||
}
|
||||
|
||||
/// Cross-project clipboard wrapper over the vendored pasteboard engine.
|
||||
///
|
||||
/// Text goes through Flutter's built-in clipboard; images and files go
|
||||
/// through the native channels (Android: FileProvider-served content URIs,
|
||||
/// Windows: GDI+/CF_HDROP, Linux: GTK).
|
||||
class ClipboardX {
|
||||
ClipboardX._();
|
||||
|
||||
static const MethodChannel _clipboardChannel = MethodChannel('fox_utils/clipboard');
|
||||
|
||||
/// Copies [bytes] to the clipboard as an image. The bytes are placed as-is
|
||||
/// (no re-encode) and the extension is sniffed from the magic bytes, so
|
||||
/// PNG/JPEG/GIF/WebP/BMP all paste with the correct MIME type.
|
||||
///
|
||||
/// Supported on Android, Windows, macOS and iOS. On Linux, raw image bytes
|
||||
/// cannot be placed on the clipboard (GTK limitation), so the bytes are
|
||||
/// staged to a temp file and copied as a file reference instead — pasted
|
||||
/// apps receive the image file.
|
||||
///
|
||||
/// Throws [ClipboardCopyException] on failure (e.g. invalid image bytes
|
||||
/// rejected by the platform).
|
||||
static Future<void> copyImage(Uint8List bytes) async {
|
||||
if (Platform.isLinux) {
|
||||
await copyFiles([await _stageLinuxImage(bytes)]);
|
||||
return;
|
||||
}
|
||||
await Pasteboard.writeImage(bytes);
|
||||
}
|
||||
|
||||
static File? _linuxStaged;
|
||||
|
||||
/// Stages [bytes] to a rotating temp file. Linux clipboards hold file
|
||||
/// references that receivers resolve at paste time, so the staged file must
|
||||
/// outlive the copy call; the previous staging file is replaced on each
|
||||
/// copy.
|
||||
static Future<String> _stageLinuxImage(Uint8List bytes) async {
|
||||
final file = File('${Directory.systemTemp.path}/fox_clipboard_image.png');
|
||||
await file.writeAsBytes(bytes);
|
||||
final old = _linuxStaged;
|
||||
if (old != null && old.path != file.path) {
|
||||
try {
|
||||
await old.delete();
|
||||
} on Exception catch (e, st) {
|
||||
// Stale staging file is harmless; log and move on.
|
||||
debugPrint('Failed to delete stale clipboard staging file: $e\n$st');
|
||||
}
|
||||
}
|
||||
_linuxStaged = file;
|
||||
return file.path;
|
||||
}
|
||||
|
||||
/// Copies [paths] (absolute file paths) to the clipboard as files.
|
||||
///
|
||||
/// Throws [ClipboardCopyException] when the native write fails or reports
|
||||
/// failure (pasteboard's `writeFiles` returns a bool that is converted to
|
||||
/// an exception here).
|
||||
static Future<void> copyFiles(List<String> paths) async {
|
||||
final ok = await Pasteboard.writeFiles(paths);
|
||||
if (!ok) {
|
||||
throw ClipboardCopyException('Failed to copy files: $paths');
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies [text] to the clipboard.
|
||||
static void copyText(String text) => Pasteboard.writeText(text);
|
||||
|
||||
static Future<String?> readText() => Pasteboard.text;
|
||||
|
||||
static Future<Uint8List?> readImage() => Pasteboard.image;
|
||||
|
||||
static Future<List<String>> readFiles() => Pasteboard.files();
|
||||
|
||||
/// Reads an image from the clipboard regardless of how it was copied:
|
||||
/// image data (a browser's "copy image") or a file reference (a file
|
||||
/// manager's copy). Returns the image bytes, or null when the clipboard
|
||||
/// holds no image.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. image data on the clipboard (browser copy) — all platforms;
|
||||
/// 2. the first file reference (file manager copy) — desktop paths are
|
||||
/// read directly, Android content URIs are resolved via the platform
|
||||
/// channel. The file's first bytes are sniffed to confirm an image
|
||||
/// before the full read, so huge non-image files are never loaded.
|
||||
static Future<Uint8List?> pasteImage() async {
|
||||
final image = await readImage();
|
||||
if (image != null) return image;
|
||||
|
||||
final files = await readFiles();
|
||||
if (files.isEmpty) return null;
|
||||
|
||||
final Uint8List? bytes;
|
||||
if (Platform.isAndroid) {
|
||||
bytes = await _readFileBytes(files.first);
|
||||
} else {
|
||||
final file = File(files.first);
|
||||
try {
|
||||
final raf = file.openSync();
|
||||
final head = raf.readSync(16);
|
||||
raf.closeSync();
|
||||
if (!_looksLikeImage(head)) return null;
|
||||
bytes = await file.readAsBytes();
|
||||
} on Exception catch (e, st) {
|
||||
debugPrint('pasteImage: could not read clipboard file ${file.path}: $e\n$st');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (bytes == null || !_looksLikeImage(bytes)) return null;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _readFileBytes(String uri) async {
|
||||
try {
|
||||
return await _clipboardChannel.invokeMethod<Uint8List>('readFileBytes', uri);
|
||||
} on PlatformException catch (e, st) {
|
||||
debugPrint('pasteImage: failed to read $uri: ${e.message}\n$st');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sniffs common image magic bytes (PNG/JPEG/GIF/WebP/BMP).
|
||||
static bool _looksLikeImage(Uint8List b) {
|
||||
if (b.length < 4) return false;
|
||||
if (b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF) return true; // jpeg
|
||||
if (b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) return true; // png
|
||||
if (b[0] == 0x47 && b[1] == 0x49 && b[2] == 0x46 && b[3] == 0x38) return true; // gif
|
||||
if (b.length >= 12 &&
|
||||
b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' &&
|
||||
b[4] == 'W' && b[5] == 'E' && b[6] == 'B' && b[7] == 'P') {
|
||||
return true; // webp
|
||||
}
|
||||
if (b[0] == 'B' && b[1] == 'M') return true; // bmp
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'dart:io' show File;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
|
||||
import 'vendor/desktop_drop.dart' show DropItem, DropItemDirectory;
|
||||
|
||||
// The widget API and drop item model come from the vendored engine:
|
||||
// DropTarget (drag-enter/exit/updated/done callbacks), DropItem / DropItemFile /
|
||||
// DropItemDirectory, DropDoneDetails and DropEventDetails.
|
||||
export 'vendor/desktop_drop.dart'
|
||||
show
|
||||
DropTarget,
|
||||
DropItem,
|
||||
DropItemFile,
|
||||
DropItemDirectory,
|
||||
DropDoneDetails,
|
||||
DropEventDetails;
|
||||
|
||||
/// Drag & drop of files and images from the OS into the app.
|
||||
///
|
||||
/// The widget side of this API is [DropTarget] (re-exported from this
|
||||
/// library). Wrap any widget in a [DropTarget] to receive drag-enter,
|
||||
/// drag-exit, drag-update and drop callbacks for OS-level drags — anything
|
||||
/// that can produce file URIs or file lists: file managers, browsers,
|
||||
/// terminals, image editors.
|
||||
///
|
||||
/// [DesktopDropX] provides the ergonomics the app needs on top of the raw
|
||||
/// [DropItem] list delivered by [DropDoneDetails.files]: extracting plain
|
||||
/// file paths and loading the first image among the dropped items.
|
||||
///
|
||||
/// Platform behavior:
|
||||
/// - **Linux**: the native side installs a GTK drag destination on the whole
|
||||
/// Flutter window and delivers `text/uri-list` (GNOME/KDE file managers,
|
||||
/// browsers, etc.). Dropped items are absolute filesystem paths.
|
||||
/// - **Windows**: the native side registers an OLE `IDropTarget` on the
|
||||
/// window and reads `CF_HDROP`, so drops work from Explorer and any
|
||||
/// browser. Dropped items are absolute filesystem paths.
|
||||
/// - **Android/iOS**: not implemented — this package does not vendor the
|
||||
/// desktop_drop mobile bindings. [DropTarget] compiles and renders its
|
||||
/// child, but no native handler answers the channel, so no drop events
|
||||
/// ever arrive.
|
||||
/// - **macOS**: not vendored (no macOS target in the app); re-adding it is
|
||||
/// a copy of upstream's `macos/` folder behind the same API.
|
||||
///
|
||||
/// Errors: nothing here throws. A drop that contains no readable image is an
|
||||
/// expected outcome (returns `null`), and file reads that fail (stale drop,
|
||||
/// permissions) are logged with a stack trace and skipped.
|
||||
class DesktopDropX {
|
||||
DesktopDropX._();
|
||||
|
||||
/// Returns one filesystem path per top-level dropped item.
|
||||
///
|
||||
/// Each item in [items] maps to its path — files and directories alike; a
|
||||
/// dropped folder yields its own path, its contents are not expanded
|
||||
/// ([DropItemDirectory.children] is only populated on macOS, which is not
|
||||
/// vendored). Items that carry no path (web data drops) yield `''`.
|
||||
///
|
||||
/// Pass [DropDoneDetails.files] from a drop callback to get the paths the
|
||||
/// app can hand to file I/O:
|
||||
///
|
||||
/// ```dart
|
||||
/// DropTarget(
|
||||
/// onDragDone: (details) {
|
||||
/// final paths = DesktopDropX.pathsOf(details.files);
|
||||
/// // read, copy, upload…
|
||||
/// },
|
||||
/// child: …,
|
||||
/// )
|
||||
/// ```
|
||||
static List<String> pathsOf(List<DropItem> items) =>
|
||||
items.map((item) => item.path).toList();
|
||||
|
||||
/// Reads the first image among [items] and returns its bytes.
|
||||
///
|
||||
/// Resolution order follows [items]: for each item the first 16 bytes are
|
||||
/// sniffed for image magic bytes (PNG/JPEG/GIF/WebP/BMP — same table as
|
||||
/// `ClipboardX.pasteImage`); the first match is loaded in full. Huge
|
||||
/// non-image files are never fully read. Returns `null` when none of the
|
||||
/// dropped items is an image, or when none could be read.
|
||||
///
|
||||
/// Directories are skipped ([DropItemDirectory] carries no file bytes).
|
||||
/// Files that fail to open or read (deleted between drop and read,
|
||||
/// permission denied) are logged with a stack trace and skipped — a later
|
||||
/// item is still tried, and the method returns `null` only if nothing
|
||||
/// readable remains.
|
||||
///
|
||||
/// The returned bytes are as-dropped (no re-encode); use the bytes with
|
||||
/// `Image.memory` or upload them as-is.
|
||||
///
|
||||
/// ```dart
|
||||
/// DropTarget(
|
||||
/// onDragDone: (details) async {
|
||||
/// final Uint8List? bytes = await DesktopDropX.readImage(details.files);
|
||||
/// if (bytes == null) return; // drop contained no image
|
||||
/// // display or upload
|
||||
/// },
|
||||
/// child: …,
|
||||
/// )
|
||||
/// ```
|
||||
static Future<Uint8List?> readImage(List<DropItem> items) async {
|
||||
for (final item in items) {
|
||||
if (item is DropItemDirectory) continue;
|
||||
final path = item.path;
|
||||
if (path.isEmpty) continue;
|
||||
final head = await _readHead(path);
|
||||
if (head == null || !_looksLikeImage(head)) continue;
|
||||
try {
|
||||
final bytes = await item.readAsBytes();
|
||||
if (bytes == null || !_looksLikeImage(bytes)) continue;
|
||||
return bytes;
|
||||
} on Exception catch (e, st) {
|
||||
debugPrint(
|
||||
'DesktopDropX.readImage: failed reading ${item.path}: $e\n$st');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _readHead(String path) async {
|
||||
try {
|
||||
final raf = File(path).openSync();
|
||||
final head = raf.readSync(16);
|
||||
raf.closeSync();
|
||||
return head;
|
||||
} on Exception catch (e, st) {
|
||||
debugPrint('DesktopDropX.readImage: could not read $path: $e\n$st');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sniffs common image magic bytes (PNG/JPEG/GIF/WebP/BMP).
|
||||
///
|
||||
/// Shares the table used by `ClipboardX._looksLikeImage`; kept as a local
|
||||
/// copy so the two APIs stay independent.
|
||||
static bool _looksLikeImage(Uint8List b) {
|
||||
if (b.length < 4) return false;
|
||||
if (b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF) return true; // jpeg
|
||||
if (b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) return true; // png
|
||||
if (b[0] == 0x47 && b[1] == 0x49 && b[2] == 0x46 && b[3] == 0x38) return true; // gif
|
||||
if (b.length >= 12 &&
|
||||
b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' &&
|
||||
b[4] == 'W' && b[5] == 'E' && b[6] == 'B' && b[7] == 'P') {
|
||||
return true; // webp
|
||||
}
|
||||
if (b[0] == 'B' && b[1] == 'M') return true; // bmp
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Executable
+211
@@ -0,0 +1,211 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
extension DioExceptionX on DioException {
|
||||
String get userMessage {
|
||||
switch (type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'Connection timeout. Please check your internet.';
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = response?.statusCode;
|
||||
if (statusCode == 401) return 'Unauthorized. Please log in again.';
|
||||
if (statusCode == 404) return 'Resource not found.';
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
return 'Server error (Status $statusCode).';
|
||||
}
|
||||
return 'Request failed with status $statusCode.';
|
||||
case DioExceptionType.cancel:
|
||||
return 'Request was cancelled.';
|
||||
case DioExceptionType.unknown:
|
||||
return 'An unexpected error occurred.';
|
||||
default:
|
||||
return 'Request failed.';
|
||||
}
|
||||
}
|
||||
|
||||
String toDebugString() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('🚨 [DioException] ${type.name}');
|
||||
buffer.writeln('📤 Request: ${requestOptions.method} ${requestOptions.uri}');
|
||||
if (response != null) {
|
||||
buffer.writeln('📥 Response: ${response!.statusCode}');
|
||||
if (response!.data != null) {
|
||||
buffer.writeln(' Body: ${_formatData(response!.data)}');
|
||||
}
|
||||
} else if (error != null) {
|
||||
buffer.writeln('⚠️ Error: ${error.toString()}');
|
||||
}
|
||||
return buffer.toString().trim();
|
||||
}
|
||||
|
||||
String _formatData(dynamic data) {
|
||||
if (data == null) return 'null';
|
||||
if (data is Map || data is List) {
|
||||
try {
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
return encoder.convert(data);
|
||||
} catch (_) {
|
||||
return data.toString();
|
||||
}
|
||||
}
|
||||
return data.toString();
|
||||
}
|
||||
}
|
||||
|
||||
extension DiscordDioException on DioException {
|
||||
/// Generates a Markdown formatted error report suitable for Discord messages.
|
||||
String toDiscordMarkdown() {
|
||||
final emoji = _getEmoji();
|
||||
final title = _getFriendlyTitle();
|
||||
final message = userMessage;
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('$emoji **$title**');
|
||||
buffer.writeln();
|
||||
buffer.writeln(message);
|
||||
buffer.writeln();
|
||||
buffer.writeln('`${requestOptions.method} ${requestOptions.uri}`');
|
||||
buffer.writeln();
|
||||
|
||||
// Technical details
|
||||
buffer.writeln('```yaml');
|
||||
buffer.writeln('Type: ${type.name}');
|
||||
if (response != null) {
|
||||
buffer.writeln('Status: ${response!.statusCode}');
|
||||
}
|
||||
if (error != null) {
|
||||
final errorStr = error.toString();
|
||||
buffer.writeln('Error: ${errorStr.length > 200 ? '${errorStr.substring(0, 200)}...' : errorStr}');
|
||||
}
|
||||
buffer.writeln('```');
|
||||
|
||||
// Ensure whole message fits in Discord's 2000‑char limit
|
||||
var result = buffer.toString().trim();
|
||||
const maxLength = 2000;
|
||||
if (result.length > maxLength) {
|
||||
result = '${result.substring(0, maxLength - 3)}...';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Human‑readable message, enhanced with the server’s own error message if available.
|
||||
String get userMessage {
|
||||
print("Server message: ${response?.data}");
|
||||
final base = _baseUserMessage();
|
||||
final server = _getServerMessage();
|
||||
if (server == null || server.isEmpty) return base;
|
||||
if (base.contains(server)) return base; // avoid duplication
|
||||
return '$base — $server';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
String _baseUserMessage() {
|
||||
switch (type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'The connection timed out.';
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
final errorStr = error?.toString().toLowerCase() ?? '';
|
||||
if (errorStr.contains('semaphore timeout') || errorStr.contains('timed out')) {
|
||||
return 'Connection timed out. The server took too long to respond.';
|
||||
}
|
||||
if (errorStr.contains('connection refused')) {
|
||||
return 'Connection refused. The server is likely offline.';
|
||||
}
|
||||
if (errorStr.contains('failed host lookup')) {
|
||||
return 'DNS Lookup Failed. Could not reach the host.';
|
||||
}
|
||||
return 'A network connection error occurred.';
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = response?.statusCode;
|
||||
// Common client errors
|
||||
if (statusCode == 400) return 'Bad Request (400).';
|
||||
if (statusCode == 401) return 'Unauthorized (401). Authentication failed.';
|
||||
if (statusCode == 403) return 'Forbidden (403). You lack permission.';
|
||||
if (statusCode == 404) return 'Not Found (404). The resource does not exist.';
|
||||
if (statusCode == 422) return 'Validation Error (422). Some fields are invalid.';
|
||||
if (statusCode == 429) return 'Too Many Requests (429). You are being rate‑limited.';
|
||||
// Server errors
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
if (statusCode == 503) return 'Service Unavailable (503).';
|
||||
return 'Server Error ($statusCode). Please try again later.';
|
||||
}
|
||||
return 'Request failed with status $statusCode.';
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return 'The request was cancelled.';
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
final errorStr = error?.toString().toLowerCase() ?? '';
|
||||
if (errorStr.contains('socketexception')) {
|
||||
if (errorStr.contains('connection refused')) return 'Server connection refused.';
|
||||
if (errorStr.contains('failed host lookup')) return 'Could not find the server address.';
|
||||
if (errorStr.contains('semaphore timeout')) return 'Network timeout.';
|
||||
return 'Network connection unstable.';
|
||||
}
|
||||
return 'An unexpected error occurred.';
|
||||
|
||||
default:
|
||||
return 'Request failed.';
|
||||
}
|
||||
}
|
||||
|
||||
String? _getServerMessage() {
|
||||
final data = response?.data;
|
||||
if (data is Map) {
|
||||
// Try common top‑level keys
|
||||
for (final key in ['message', 'error', 'detail', 'description']) {
|
||||
final val = data[key];
|
||||
if (val is String && val.trim().isNotEmpty) return val.trim();
|
||||
}
|
||||
// Nested errors map (e.g. { "errors": { "email": ["is taken"] } })
|
||||
final errors = data['errors'];
|
||||
if (errors is Map) {
|
||||
for (final entry in errors.entries) {
|
||||
final v = entry.value;
|
||||
if (v is List && v.isNotEmpty) return '${entry.key}: ${v.first}';
|
||||
if (v is String && v.isNotEmpty) return '${entry.key}: $v';
|
||||
}
|
||||
}
|
||||
} else if (data is List && data.isNotEmpty) {
|
||||
if (data.first is Map) {
|
||||
return _getServerMessageFromMap(data.first as Map);
|
||||
}
|
||||
} else if (data is String && data.isNotEmpty) {
|
||||
// Plain text body – use it directly, but keep it short
|
||||
return data.length > 200 ? '${data.substring(0, 200)}...' : data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper for when the first element of a list is a map
|
||||
String? _getServerMessageFromMap(Map map) {
|
||||
print("Server has map: ${map}");
|
||||
for (final key in ['message', 'error', 'detail']) {
|
||||
final val = map[key];
|
||||
if (val is String && val.trim().isNotEmpty) {
|
||||
return val.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _getFriendlyTitle() {
|
||||
if (type == DioExceptionType.badResponse) return 'API Error';
|
||||
return 'Network Error';
|
||||
}
|
||||
|
||||
String _getEmoji() {
|
||||
if (type == DioExceptionType.badResponse) return '⚠️';
|
||||
return '⛔';
|
||||
}
|
||||
}
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dio_exceptions.dart'; // Assuming they are in the same package
|
||||
|
||||
class ErrorWindow extends StatelessWidget {
|
||||
final Object? originalError;
|
||||
final Widget? child; // For retry buttons, etc.
|
||||
|
||||
const ErrorWindow(this.originalError, {this.child, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final error = originalError;
|
||||
|
||||
Widget errorContent = Text(
|
||||
'An unexpected error occurred:\n${error.toString()}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
);
|
||||
|
||||
if (error is DioException) {
|
||||
errorContent = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildFriendlyHeader(context, error),
|
||||
const Divider(height: 30),
|
||||
Text(
|
||||
'Technical Details:',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SelectableText(
|
||||
error.toDebugString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 600, minHeight: 200),
|
||||
padding: const EdgeInsets.all(20),
|
||||
margin: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
errorContent,
|
||||
if (child != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: Align(alignment: Alignment.bottomRight, child: child),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFriendlyHeader(BuildContext context, DioException error) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.warning_rounded, color: Colors.redAccent, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
error.userMessage,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Request: ${error.requestOptions.method} ${error.requestOptions.uri.path}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
export 'android_files.dart';
|
||||
export 'clipboard_x.dart';
|
||||
export 'desktop_drop_x.dart';
|
||||
|
||||
/// A Calculator.
|
||||
class Calculator {
|
||||
/// Returns [value] plus 1.
|
||||
int addOne(int value) => value + 1;
|
||||
}
|
||||
Executable
+420
@@ -0,0 +1,420 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GradientText extends StatelessWidget {
|
||||
const GradientText(this.text, {super.key, required this.gradient});
|
||||
|
||||
final Widget text;
|
||||
final Gradient gradient;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ShaderMask(
|
||||
blendMode: BlendMode.srcIn,
|
||||
shaderCallback: (bounds) => gradient.createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
|
||||
child: text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum CornerSide { topLeft, topRight, bottomLeft, bottomRight }
|
||||
|
||||
class SingleScoopedCornerClipper extends CustomClipper<Path> {
|
||||
SingleScoopedCornerClipper({required this.radius, this.side = CornerSide.topRight});
|
||||
|
||||
final double radius;
|
||||
final CornerSide side;
|
||||
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final path = Path();
|
||||
final w = size.width;
|
||||
final h = size.height;
|
||||
|
||||
// Start at top-left
|
||||
path.moveTo(0, 0);
|
||||
|
||||
switch (side) {
|
||||
case CornerSide.topRight:
|
||||
// Draw top edge to where the curve starts (right side)
|
||||
path.lineTo(w - radius, 0);
|
||||
// Concave arc
|
||||
path.arcToPoint(Offset(w, radius), radius: Radius.circular(radius), clockwise: false);
|
||||
// Draw remainder of edges
|
||||
path.lineTo(w, h);
|
||||
path.lineTo(0, h);
|
||||
break;
|
||||
|
||||
case CornerSide.bottomRight:
|
||||
// Draw right edge to where the curve starts (bottom side)
|
||||
path.lineTo(w, h - radius);
|
||||
// Concave arc
|
||||
path.arcToPoint(Offset(w - radius, h), radius: Radius.circular(radius), clockwise: false);
|
||||
// Draw remainder of edges
|
||||
path.lineTo(0, h);
|
||||
break;
|
||||
|
||||
case CornerSide.bottomLeft:
|
||||
// Draw bottom edge to where the curve starts (left side)
|
||||
path.lineTo(radius, h);
|
||||
// Concave arc
|
||||
path.arcToPoint(Offset(0, h - radius), radius: Radius.circular(radius), clockwise: false);
|
||||
// Draw remainder of edges
|
||||
path.lineTo(0, 0); // Already moved here, just closing path
|
||||
break;
|
||||
|
||||
case CornerSide.topLeft:
|
||||
// Draw left edge to where the curve starts (top side)
|
||||
path.lineTo(0, radius);
|
||||
// Concave arc
|
||||
path.arcToPoint(Offset(radius, 0), radius: Radius.circular(radius), clockwise: false);
|
||||
// Draw remainder of edges
|
||||
path.lineTo(w, 0);
|
||||
path.lineTo(w, h);
|
||||
path.lineTo(0, h);
|
||||
break;
|
||||
}
|
||||
|
||||
path.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(SingleScoopedCornerClipper oldClipper) => oldClipper.radius != radius || oldClipper.side != side;
|
||||
}
|
||||
|
||||
extension IntBytesExtension on int {
|
||||
/// Converts to Kilobytes (1024 bytes)
|
||||
int get kb => this * 1024;
|
||||
|
||||
/// Converts to Megabytes (1024 * 1024 bytes)
|
||||
int get mb => this * 1024 * 1024;
|
||||
|
||||
/// Converts to Gigabytes (1024 * 1024 * 1024 bytes)
|
||||
int get gb => this * 1024 * 1024 * 1024;
|
||||
|
||||
/// Converts to Terabytes (1024^4 bytes)
|
||||
int get tb => this * 1024 * 1024 * 1024 * 1024;
|
||||
}
|
||||
|
||||
String formatBytes(int bytes, {int decimals = 2, bool useBinary = true}) {
|
||||
if (bytes <= 0) return "0 B";
|
||||
|
||||
// Choose between binary (1024) or decimal (1000) base
|
||||
final base = useBinary ? 1024.0 : 1000.0;
|
||||
final units = useBinary
|
||||
? ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
|
||||
: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
final exp = (log(bytes) / log(base)).floor();
|
||||
final size = bytes / pow(base, exp);
|
||||
|
||||
// Clamp the exponent to avoid index out of bounds
|
||||
final unitIndex = exp.clamp(0, units.length - 1);
|
||||
|
||||
return '${size.toStringAsFixed(decimals)} ${units[unitIndex]}';
|
||||
}
|
||||
|
||||
// Extension method for easier usage
|
||||
extension BytesFormatter on int {
|
||||
String toReadableSize({int decimals = 2}) => formatBytes(this, decimals: decimals);
|
||||
}
|
||||
|
||||
extension DateTimeExtension on DateTime {
|
||||
String timeAgo() {
|
||||
final diff = DateTime.now().difference(this);
|
||||
if (diff.inDays >= 1) return '${diff.inDays} days ago';
|
||||
if (diff.inHours >= 1) return '${diff.inHours} hours ago';
|
||||
if (diff.inMinutes >= 1) return '${diff.inMinutes} minutes ago';
|
||||
return 'Just now';
|
||||
}
|
||||
|
||||
String toSmartDateWithTime() {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(this);
|
||||
|
||||
// Helper for "14:30" format
|
||||
final clockTime = '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
|
||||
|
||||
String datePart;
|
||||
|
||||
// 1. Just now
|
||||
if (diff.inMinutes < 1) {
|
||||
datePart = 'just now';
|
||||
}
|
||||
// 2. Minute precision (up to 1 hour)
|
||||
else if (diff.inHours < 1) {
|
||||
datePart = '${diff.inMinutes}m ago';
|
||||
}
|
||||
// 3. Hour precision (up to 24 hours)
|
||||
else if (diff.inDays < 1) {
|
||||
datePart = '${diff.inHours}h ago';
|
||||
}
|
||||
// 4. Compact date (older than a day)
|
||||
else if (year == now.year) {
|
||||
datePart = '${_getMonth(month)} $day';
|
||||
} else {
|
||||
datePart = '${_getMonth(month)} $day, $year';
|
||||
}
|
||||
|
||||
// Combine them: "5m ago at 14:30" or "Jan 2 at 07:11"
|
||||
return '$datePart at $clockTime';
|
||||
}
|
||||
|
||||
String _getMonth(int month) {
|
||||
return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][month - 1];
|
||||
}
|
||||
|
||||
String _getWeekday(int day) {
|
||||
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][day - 1];
|
||||
}
|
||||
}
|
||||
|
||||
extension CompactDateTime on DateTime {
|
||||
String toCompactPostDate() {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(this);
|
||||
|
||||
// 1. Less than a minute: "Just now"
|
||||
if (difference.inMinutes < 1) {
|
||||
return 'now';
|
||||
}
|
||||
|
||||
// 2. Less than an hour: "5m", "59m"
|
||||
if (difference.inHours < 1) {
|
||||
return '${difference.inMinutes}m';
|
||||
}
|
||||
|
||||
// 3. Less than a day: "2h", "23h"
|
||||
if (difference.inDays < 1) {
|
||||
return '${difference.inHours}h';
|
||||
}
|
||||
|
||||
// 4. Less than a week: "Mon", "Tue"
|
||||
if (difference.inDays < 7) {
|
||||
return _getWeekday(weekday);
|
||||
}
|
||||
|
||||
// 5. This calendar year: "Mar 12"
|
||||
if (year == now.year) {
|
||||
return '${_getMonth(month)} $day';
|
||||
}
|
||||
|
||||
// 6. Different year: "Mar 2024"
|
||||
return '${_getMonth(month)} $year';
|
||||
}
|
||||
|
||||
// Helper for short weekdays
|
||||
String _getWeekday(int day) {
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
return days[day - 1];
|
||||
}
|
||||
|
||||
// Helper for short months
|
||||
String _getMonth(int month) {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return months[month - 1];
|
||||
}
|
||||
}
|
||||
|
||||
extension TimeGradientColor on DateTime {
|
||||
Color toSkyColor(Color baseColor) {
|
||||
// Define the color transition for a full 24-hour cycle
|
||||
const skyline = LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF0F172A), // 00:00 - Deep Night
|
||||
Color(0xFF334155), // 06:00 - Dawn
|
||||
Color(0xFF7DD3FC), // 12:00 - Noon
|
||||
Color(0xFFFB923C), // 18:00 - Sunset
|
||||
Color(0xFF0F172A), // 24:00 - Back to Night
|
||||
],
|
||||
stops: [0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
);
|
||||
|
||||
// Calculate the day's progress (0.0 to 1.0)
|
||||
final double dayProgress = (hour * 60 + minute) / (24 * 60);
|
||||
|
||||
// Sample the color from the gradient
|
||||
final Color sampledColor = _sampleGradient(skyline, dayProgress);
|
||||
|
||||
// Blend it subtly with your theme's surface color (15% intensity)
|
||||
return Color.lerp(baseColor, sampledColor, 0.15)!;
|
||||
}
|
||||
|
||||
// Helper to interpolate color from a LinearGradient
|
||||
Color _sampleGradient(LinearGradient gradient, double t) {
|
||||
if (t <= 0) return gradient.colors.first;
|
||||
if (t >= 1) return gradient.colors.last;
|
||||
|
||||
final int segmentCount = gradient.colors.length - 1;
|
||||
final double segmentValue = t * segmentCount;
|
||||
final int index = segmentValue.floor();
|
||||
final double weight = segmentValue - index;
|
||||
|
||||
return Color.lerp(gradient.colors[index], gradient.colors[index + 1], weight)!;
|
||||
}
|
||||
}
|
||||
|
||||
extension NullableProgress01 on ImageChunkEvent? {
|
||||
/// 0.0 → 1.0 while loading; null when unknown.
|
||||
double? get progress {
|
||||
final chunk = this;
|
||||
if (chunk == null) return null;
|
||||
|
||||
final total = chunk.expectedTotalBytes;
|
||||
return (total != null && total > 0) ? (chunk.cumulativeBytesLoaded / total).clamp(0.0, 1.0) : null;
|
||||
}
|
||||
}
|
||||
|
||||
extension ObjectExt<T> on T {
|
||||
R let<R>(R Function(T e) block) => block(this);
|
||||
}
|
||||
|
||||
extension ColorUtils on Color {
|
||||
// ---------------------------------------------------------
|
||||
// 1. Brightness Manipulation (HSL Based)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// Darkens the color by a given [amount] (0.0 to 1.0).
|
||||
///
|
||||
/// Defaults to 10% (0.1).
|
||||
/// Uses HSL calculation to preserve color hue.
|
||||
// Color darken([double amount = 0.1]) {
|
||||
// assert(amount >= 0 && amount <= 1, 'Amount must be between 0 and 1');
|
||||
|
||||
// final hsl = HSLColor.fromColor(this);
|
||||
// final newLightness = (hsl.lightness - amount).clamp(0.0, 1.0);
|
||||
|
||||
// return hsl.withLightness(newLightness).toColor();
|
||||
// }
|
||||
|
||||
// /// Lightens the color by a given [amount] (0.0 to 1.0).
|
||||
// ///
|
||||
// /// Defaults to 10% (0.1).
|
||||
// /// Uses HSL calculation to preserve color hue.
|
||||
// Color lighten([double amount = 0.1]) {
|
||||
// assert(amount >= 0 && amount <= 1, 'Amount must be between 0 and 1');
|
||||
|
||||
// final hsl = HSLColor.fromColor(this);
|
||||
// final newLightness = (hsl.lightness + amount).clamp(0.0, 1.0);
|
||||
|
||||
// return hsl.withLightness(newLightness).toColor();
|
||||
// }
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 2. Opacity & Color Mixing
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// Returns a new color with the specified opacity.
|
||||
/// Shortcut for `withOpacity`.
|
||||
Color withOpacityValue(double opacity) {
|
||||
return withOpacity(opacity);
|
||||
}
|
||||
|
||||
/// Blends this color with another color.
|
||||
///
|
||||
/// [amount] defines how much of [color] to mix in (0.0 to 1.0).
|
||||
/// 0.0 results in the original color, 1.0 results in the input color.
|
||||
Color mix(Color color, [double amount = 0.5]) {
|
||||
assert(amount >= 0 && amount <= 1, 'Amount must be between 0 and 1');
|
||||
|
||||
return Color.lerp(this, color, amount)!;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3. Color Properties & Conversion
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// Returns the color as a Hex string (e.g. "#FF0000").
|
||||
/// Includes alpha channel if [includeAlpha] is true.
|
||||
String toHexString({bool includeAlpha = false}) {
|
||||
if (includeAlpha) {
|
||||
return '#${value.toRadixString(16).padLeft(8, '0').toUpperCase()}';
|
||||
}
|
||||
return '#${value.toRadixString(16).substring(2).padLeft(6, '0').toUpperCase()}';
|
||||
}
|
||||
|
||||
/// Calculates the relative luminance.
|
||||
/// Useful for determining if the color is "light" or "dark".
|
||||
bool get isDark => computeLuminance() < 0.5;
|
||||
|
||||
bool get isLight => !isDark;
|
||||
|
||||
/// Returns a contrasting text color (Black or White)
|
||||
/// suitable for reading on top of this color.
|
||||
Color get contrastingColor => isDark ? Colors.white : Colors.black;
|
||||
}
|
||||
|
||||
/// Optional: Extension on String to parse Hex colors easily.
|
||||
extension StringColorUtils on String {
|
||||
/// Parses a hex string to a Color.
|
||||
/// Supports formats: "FF0000", "#FF0000", "0xFF0000", "#FFFF0000".
|
||||
Color toColor() {
|
||||
var hexColor = replaceAll('#', '').replaceAll('0x', '');
|
||||
|
||||
if (hexColor.length == 6) {
|
||||
hexColor = 'FF$hexColor'; // Add full opacity if alpha is missing
|
||||
}
|
||||
|
||||
return Color(int.parse(hexColor, radix: 16));
|
||||
}
|
||||
}
|
||||
|
||||
enum Side { top, left, bottom, right }
|
||||
|
||||
extension NumBorderRadiusExtension on num {
|
||||
/// Creates a circular radius where specific sides are flattened (radius = 0).
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// // A pill shape flattened on the left side
|
||||
/// Container(
|
||||
/// decoration: BoxDecoration(
|
||||
/// borderRadius: 50.circularExcept({Side.left}),
|
||||
/// color: Colors.blue,
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
BorderRadius circularExcept(Set<Side> excludedSides) {
|
||||
final double r = toDouble();
|
||||
|
||||
// If a side is in the exclusion set, its corners become 0.
|
||||
// Otherwise, they take the radius [r].
|
||||
|
||||
// Top-Left is affected by Top OR Left
|
||||
final tl = (excludedSides.contains(Side.top) || excludedSides.contains(Side.left)) ? 0.0 : r;
|
||||
|
||||
// Top-Right is affected by Top OR Right
|
||||
final tr = (excludedSides.contains(Side.top) || excludedSides.contains(Side.right)) ? 0.0 : r;
|
||||
|
||||
// Bottom-Left is affected by Bottom OR Left
|
||||
final bl = (excludedSides.contains(Side.bottom) || excludedSides.contains(Side.left)) ? 0.0 : r;
|
||||
|
||||
// Bottom-Right is affected by Bottom OR Right
|
||||
final br = (excludedSides.contains(Side.bottom) || excludedSides.contains(Side.right)) ? 0.0 : r;
|
||||
|
||||
return BorderRadius.only(
|
||||
topLeft: Radius.circular(tl),
|
||||
topRight: Radius.circular(tr),
|
||||
bottomLeft: Radius.circular(bl),
|
||||
bottomRight: Radius.circular(br),
|
||||
);
|
||||
}
|
||||
|
||||
BorderRadius get circular {
|
||||
return BorderRadius.circular(this.toDouble());
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: Extension on BorderRadius itself if you want to modify an existing one
|
||||
extension BorderRadiusModifier on BorderRadius {
|
||||
BorderRadius exclude(Set<Side> sides) {
|
||||
return copyWith(
|
||||
topLeft: sides.contains(Side.top) || sides.contains(Side.left) ? Radius.zero : topLeft,
|
||||
topRight: sides.contains(Side.top) || sides.contains(Side.right) ? Radius.zero : topRight,
|
||||
bottomLeft: sides.contains(Side.bottom) || sides.contains(Side.left) ? Radius.zero : bottomLeft,
|
||||
bottomRight: sides.contains(Side.bottom) || sides.contains(Side.right) ? Radius.zero : bottomRight,
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export 'src/drop_target.dart';
|
||||
export 'src/drop_item.dart';
|
||||
export 'src/channel.dart';
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'src/pasteboard_platform_web.dart'
|
||||
if (dart.library.io) 'src/pasteboard_platform_io.dart';
|
||||
|
||||
class Pasteboard {
|
||||
/// Returns the image data of the pasteboard.
|
||||
///
|
||||
/// available on iOS, Desktop, Web, Android
|
||||
static Future<Uint8List?> get image => pasteboard.image;
|
||||
|
||||
/// only available on Windows and the web.
|
||||
/// Get "HTML format" from system pasteboard.
|
||||
/// HTML format: https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767917(v=vs.85)
|
||||
static Future<String?> get html => pasteboard.html;
|
||||
|
||||
/// available on iOS, Android, Web
|
||||
///
|
||||
/// set image data to system pasteboard.
|
||||
static Future<void> writeImage(Uint8List? image) =>
|
||||
pasteboard.writeImage(image);
|
||||
|
||||
/// available on Desktop, Android.
|
||||
///
|
||||
/// Android: paths are content uris, must read by content resolver.
|
||||
///
|
||||
/// Get files from system pasteboard.
|
||||
static Future<List<String>> files() => pasteboard.files();
|
||||
|
||||
/// Only available on desktop platforms.
|
||||
///
|
||||
/// Set files to system pasteboard.
|
||||
static Future<bool> writeFiles(List<String> files) =>
|
||||
pasteboard.writeFiles(files);
|
||||
|
||||
/// Available on all platforms.
|
||||
/// Get text from system pasteboard.
|
||||
static Future<String?> get text => pasteboard.text;
|
||||
|
||||
/// Available on all platforms.
|
||||
/// Set text to system pasteboard.
|
||||
static void writeText(String value) => pasteboard.writeText(value);
|
||||
}
|
||||
Vendored
+190
@@ -0,0 +1,190 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fox_utils/vendor/src/drop_item.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'events.dart';
|
||||
import 'web_drop_item.dart';
|
||||
|
||||
typedef RawDropListener = void Function(DropEvent);
|
||||
|
||||
class DesktopDrop {
|
||||
static const MethodChannel _channel = MethodChannel('desktop_drop');
|
||||
|
||||
DesktopDrop._();
|
||||
|
||||
static final instance = DesktopDrop._();
|
||||
|
||||
final _listeners = <RawDropListener>{};
|
||||
|
||||
var _inited = false;
|
||||
|
||||
Offset? _offset;
|
||||
|
||||
void init() {
|
||||
if (_inited) {
|
||||
return;
|
||||
}
|
||||
_inited = true;
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
try {
|
||||
return await _handleMethodChannel(call);
|
||||
} catch (e, s) {
|
||||
debugPrint('_handleMethodChannel: $e $s');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// macOS: Attempt to start security-scoped access for a bookmarked URL.
|
||||
///
|
||||
/// Pass the [DropItem.extraAppleBookmark] bytes from a dropped file that
|
||||
/// originated outside the app container. Returns `true` if access began.
|
||||
///
|
||||
/// If [bookmark] is empty, this function returns `false` and does not
|
||||
/// invoke the platform call. Promise files written under your container do
|
||||
/// not require security-scoped access.
|
||||
Future<bool> startAccessingSecurityScopedResource(
|
||||
{required Uint8List bookmark}) async {
|
||||
if (bookmark.isEmpty) return false;
|
||||
Map<String, dynamic> resultMap = {};
|
||||
resultMap["apple-bookmark"] = bookmark;
|
||||
final bool? result = await _channel.invokeMethod(
|
||||
"startAccessingSecurityScopedResource", resultMap);
|
||||
if (result == null) return false;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// macOS: Stop security-scoped access previously started.
|
||||
///
|
||||
/// If [bookmark] is empty, this function returns `true` and does not
|
||||
/// invoke the platform call, acting as a no-op.
|
||||
Future<bool> stopAccessingSecurityScopedResource(
|
||||
{required Uint8List bookmark}) async {
|
||||
if (bookmark.isEmpty) return true;
|
||||
Map<String, dynamic> resultMap = {};
|
||||
resultMap["apple-bookmark"] = bookmark;
|
||||
final bool result = await _channel.invokeMethod(
|
||||
"stopAccessingSecurityScopedResource", resultMap);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _handleMethodChannel(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case "entered":
|
||||
final position = (call.arguments as List).cast<double>();
|
||||
_offset = Offset(position[0], position[1]);
|
||||
_notifyEvent(DropEnterEvent(location: _offset!));
|
||||
break;
|
||||
case "updated":
|
||||
if (_offset == null && UniversalPlatform.isLinux) {
|
||||
final position = (call.arguments as List).cast<double>();
|
||||
_offset = Offset(position[0], position[1]);
|
||||
_notifyEvent(DropEnterEvent(location: _offset!));
|
||||
return;
|
||||
}
|
||||
final position = (call.arguments as List).cast<double>();
|
||||
_offset = Offset(position[0], position[1]);
|
||||
_notifyEvent(DropUpdateEvent(location: _offset!));
|
||||
break;
|
||||
case "exited":
|
||||
_notifyEvent(DropExitEvent(location: _offset ?? Offset.zero));
|
||||
_offset = null;
|
||||
break;
|
||||
case "performOperation":
|
||||
final paths = (call.arguments as List).cast<String>();
|
||||
_notifyEvent(
|
||||
DropDoneEvent(
|
||||
location: _offset ?? Offset.zero,
|
||||
files: paths.map((e) => DropItemFile(e)).toList(),
|
||||
),
|
||||
);
|
||||
_offset = null;
|
||||
break;
|
||||
case "performOperation_macos":
|
||||
final items = (call.arguments as List).cast<Map>();
|
||||
_notifyEvent(
|
||||
DropDoneEvent(
|
||||
location: _offset ?? Offset.zero,
|
||||
files: items.map((raw) {
|
||||
final path = raw["path"] as String;
|
||||
final bookmark = raw["apple-bookmark"] as Uint8List?;
|
||||
final isDir = (raw["isDirectory"] as bool?) ?? false;
|
||||
final fromPromise = (raw["fromPromise"] as bool?) ?? false;
|
||||
if (isDir) {
|
||||
return DropItemDirectory(
|
||||
path,
|
||||
const [],
|
||||
extraAppleBookmark: bookmark,
|
||||
fromPromise: fromPromise,
|
||||
);
|
||||
}
|
||||
return DropItemFile(
|
||||
path,
|
||||
extraAppleBookmark: bookmark,
|
||||
fromPromise: fromPromise,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
_offset = null;
|
||||
break;
|
||||
|
||||
case "performOperation_linux":
|
||||
// gtk notify 'exit' before 'performOperation'.
|
||||
final text = (call.arguments as List<dynamic>)[0] as String;
|
||||
final offset = ((call.arguments as List<dynamic>)[1] as List<dynamic>)
|
||||
.cast<double>();
|
||||
final paths = const LineSplitter().convert(text).map((e) {
|
||||
try {
|
||||
final uri = Uri.tryParse(e);
|
||||
if (uri == null) {
|
||||
return '';
|
||||
}
|
||||
if (uri.scheme == 'file') {
|
||||
return uri.toFilePath();
|
||||
}
|
||||
return e;
|
||||
} catch (error, stacktrace) {
|
||||
debugPrint('failed to parse linux path: $error $stacktrace');
|
||||
}
|
||||
return '';
|
||||
}).where((e) => e.isNotEmpty);
|
||||
_notifyEvent(DropDoneEvent(
|
||||
location: Offset(offset[0], offset[1]),
|
||||
files: paths.map((e) => DropItemFile(e)).toList(),
|
||||
));
|
||||
break;
|
||||
case "performOperation_web":
|
||||
final results = (call.arguments as List)
|
||||
.cast<Map>()
|
||||
.map((e) => WebDropItem.fromJson(e.cast<String, dynamic>()))
|
||||
.map((e) => e.toDropItem())
|
||||
.toList();
|
||||
_notifyEvent(
|
||||
DropDoneEvent(location: _offset ?? Offset.zero, files: results),
|
||||
);
|
||||
_offset = null;
|
||||
break;
|
||||
default:
|
||||
throw UnimplementedError('${call.method} not implement.');
|
||||
}
|
||||
}
|
||||
|
||||
void _notifyEvent(DropEvent event) {
|
||||
for (final listener in _listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
void addRawDropEventListener(RawDropListener listener) {
|
||||
assert(!_listeners.contains(listener));
|
||||
_listeners.add(listener);
|
||||
}
|
||||
|
||||
void removeRawDropEventListener(RawDropListener listener) {
|
||||
assert(_listeners.contains(listener));
|
||||
_listeners.remove(listener);
|
||||
}
|
||||
}
|
||||
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
|
||||
/// A dropped item.
|
||||
///
|
||||
/// On desktop, this is usually a filesystem path (file or directory).
|
||||
///
|
||||
/// macOS specifics:
|
||||
/// - If the drag source provided a real file URL (e.g. Finder/JetBrains),
|
||||
/// [extraAppleBookmark] will typically be non-null and allow security-scoped
|
||||
/// access when running sandboxed.
|
||||
/// - If the drag source used a file promise (e.g. VS Code/Electron), the
|
||||
/// system delivers bytes into a per-drop temporary folder inside your app's
|
||||
/// container. In that case [fromPromise] is true and [extraAppleBookmark]
|
||||
/// is usually null/empty. There is no original source path in this flow.
|
||||
abstract class DropItem extends XFile {
|
||||
/// Security-scoped bookmark bytes for the dropped item (macOS only).
|
||||
///
|
||||
/// Use with [DesktopDrop.startAccessingSecurityScopedResource] to gain
|
||||
/// temporary access to files outside your sandbox. When empty or null,
|
||||
/// you typically don't need to call start/stop (e.g. promise files in
|
||||
/// your app's container).
|
||||
Uint8List? extraAppleBookmark;
|
||||
|
||||
/// True when this item was delivered via a macOS file promise and was
|
||||
/// written into your app's temporary Drops directory.
|
||||
///
|
||||
/// In this case, the original source path is not available by design.
|
||||
final bool fromPromise;
|
||||
DropItem(
|
||||
super.path, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.bytes,
|
||||
super.lastModified,
|
||||
this.extraAppleBookmark,
|
||||
this.fromPromise = false,
|
||||
});
|
||||
|
||||
DropItem.fromData(
|
||||
super.bytes, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.lastModified,
|
||||
super.path,
|
||||
this.extraAppleBookmark,
|
||||
this.fromPromise = false,
|
||||
}) : super.fromData();
|
||||
}
|
||||
|
||||
class DropItemFile extends DropItem {
|
||||
DropItemFile(
|
||||
super.path, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.bytes,
|
||||
super.lastModified,
|
||||
super.extraAppleBookmark,
|
||||
super.fromPromise,
|
||||
});
|
||||
|
||||
DropItemFile.fromData(
|
||||
super.bytes, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.lastModified,
|
||||
super.path,
|
||||
super.fromPromise,
|
||||
}) : super.fromData();
|
||||
}
|
||||
|
||||
/// A dropped directory.
|
||||
class DropItemDirectory extends DropItem {
|
||||
final List<DropItem> children;
|
||||
|
||||
DropItemDirectory(
|
||||
super.path,
|
||||
this.children, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.bytes,
|
||||
super.lastModified,
|
||||
super.extraAppleBookmark,
|
||||
super.fromPromise,
|
||||
});
|
||||
|
||||
DropItemDirectory.fromData(
|
||||
super.bytes,
|
||||
this.children, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.lastModified,
|
||||
super.path,
|
||||
super.extraAppleBookmark,
|
||||
super.fromPromise,
|
||||
}) : super.fromData();
|
||||
}
|
||||
Vendored
+220
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'channel.dart';
|
||||
import 'drop_item.dart';
|
||||
import 'events.dart';
|
||||
|
||||
@immutable
|
||||
class DropDoneDetails {
|
||||
const DropDoneDetails({
|
||||
required this.files,
|
||||
required this.localPosition,
|
||||
required this.globalPosition,
|
||||
});
|
||||
|
||||
final List<DropItem> files;
|
||||
final Offset localPosition;
|
||||
final Offset globalPosition;
|
||||
}
|
||||
|
||||
class DropEventDetails {
|
||||
DropEventDetails({
|
||||
required this.localPosition,
|
||||
required this.globalPosition,
|
||||
});
|
||||
|
||||
final Offset localPosition;
|
||||
|
||||
final Offset globalPosition;
|
||||
}
|
||||
|
||||
typedef OnDragDoneCallback = void Function(DropDoneDetails details);
|
||||
|
||||
typedef OnDragCallback<Detail> = void Function(Detail details);
|
||||
|
||||
/// A widget that accepts draggable files.
|
||||
class DropTarget extends StatefulWidget {
|
||||
const DropTarget({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onDragEntered,
|
||||
this.onDragExited,
|
||||
this.onDragDone,
|
||||
this.onDragUpdated,
|
||||
this.enable = true,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Callback when drag entered target area.
|
||||
final OnDragCallback<DropEventDetails>? onDragEntered;
|
||||
|
||||
/// Callback when drag exited target area.
|
||||
final OnDragCallback<DropEventDetails>? onDragExited;
|
||||
|
||||
/// Callback when drag hover on target area.
|
||||
final OnDragCallback<DropEventDetails>? onDragUpdated;
|
||||
|
||||
/// Callback when drag dropped on target area.
|
||||
final OnDragDoneCallback? onDragDone;
|
||||
|
||||
/// Whether to enable drop target.
|
||||
///
|
||||
/// ATTENTION: You should disable drop target when you push a new page/widget in
|
||||
/// front of this drop target, since the drop target will still receive drag events
|
||||
/// even it is invisible.
|
||||
/// https://github.com/MixinNetwork/flutter-plugins/issues/2
|
||||
final bool enable;
|
||||
|
||||
@override
|
||||
State<DropTarget> createState() => _DropTargetState();
|
||||
}
|
||||
|
||||
enum _DragTargetStatus {
|
||||
enter,
|
||||
update,
|
||||
idle,
|
||||
}
|
||||
|
||||
class _DropTargetState extends State<DropTarget> {
|
||||
_DragTargetStatus _status = _DragTargetStatus.idle;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
DesktopDrop.instance.init();
|
||||
if (widget.enable) {
|
||||
DesktopDrop.instance.addRawDropEventListener(_onDropEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DropTarget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.enable && !oldWidget.enable) {
|
||||
DesktopDrop.instance.addRawDropEventListener(_onDropEvent);
|
||||
} else if (!widget.enable && oldWidget.enable) {
|
||||
DesktopDrop.instance.removeRawDropEventListener(_onDropEvent);
|
||||
if (_status != _DragTargetStatus.idle) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.idle,
|
||||
localLocation: Offset.zero,
|
||||
globalLocation: Offset.zero,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onDropEvent(DropEvent event) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) {
|
||||
return;
|
||||
}
|
||||
final globalPosition = _scaleHoverPoint(context, event.location);
|
||||
final position = renderBox.globalToLocal(globalPosition);
|
||||
bool inBounds = renderBox.paintBounds.contains(position);
|
||||
if (event is DropEnterEvent) {
|
||||
if (!inBounds) {
|
||||
assert(_status == _DragTargetStatus.idle);
|
||||
} else {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.enter,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
}
|
||||
} else if (event is DropUpdateEvent) {
|
||||
if (_status == _DragTargetStatus.idle && inBounds) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.enter,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
} else if ((_status == _DragTargetStatus.enter ||
|
||||
_status == _DragTargetStatus.update) &&
|
||||
inBounds) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.update,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
debugRequiredStatus: false,
|
||||
);
|
||||
} else if (_status != _DragTargetStatus.idle && !inBounds) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.idle,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
}
|
||||
} else if (event is DropExitEvent && _status != _DragTargetStatus.idle) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.idle,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
} else if (event is DropDoneEvent &&
|
||||
(_status != _DragTargetStatus.idle || UniversalPlatform.isLinux) &&
|
||||
inBounds) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.idle,
|
||||
debugRequiredStatus: false,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
widget.onDragDone?.call(DropDoneDetails(
|
||||
files: event.files,
|
||||
localPosition: position,
|
||||
globalPosition: globalPosition,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _updateStatus(
|
||||
_DragTargetStatus status, {
|
||||
bool debugRequiredStatus = true,
|
||||
required Offset localLocation,
|
||||
required Offset globalLocation,
|
||||
}) {
|
||||
assert(!debugRequiredStatus || _status != status);
|
||||
_status = status;
|
||||
final details = DropEventDetails(
|
||||
localPosition: localLocation,
|
||||
globalPosition: globalLocation,
|
||||
);
|
||||
switch (_status) {
|
||||
case _DragTargetStatus.enter:
|
||||
widget.onDragEntered?.call(details);
|
||||
break;
|
||||
case _DragTargetStatus.update:
|
||||
widget.onDragUpdated?.call(details);
|
||||
break;
|
||||
case _DragTargetStatus.idle:
|
||||
widget.onDragExited?.call(details);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.enable) {
|
||||
DesktopDrop.instance.removeRawDropEventListener(_onDropEvent);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
Offset _scaleHoverPoint(BuildContext context, Offset point) {
|
||||
if (UniversalPlatform.isWindows || UniversalPlatform.isAndroid) {
|
||||
return point.scale(
|
||||
1 / MediaQuery.of(context).devicePixelRatio,
|
||||
1 / MediaQuery.of(context).devicePixelRatio,
|
||||
);
|
||||
}
|
||||
return point;
|
||||
}
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
import 'package:fox_utils/vendor/src/drop_item.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
abstract class DropEvent {
|
||||
Offset location;
|
||||
|
||||
DropEvent(this.location);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType($location)';
|
||||
}
|
||||
}
|
||||
|
||||
class DropEnterEvent extends DropEvent {
|
||||
DropEnterEvent({required Offset location}) : super(location);
|
||||
}
|
||||
|
||||
class DropExitEvent extends DropEvent {
|
||||
DropExitEvent({required Offset location}) : super(location);
|
||||
}
|
||||
|
||||
class DropUpdateEvent extends DropEvent {
|
||||
DropUpdateEvent({required Offset location}) : super(location);
|
||||
}
|
||||
|
||||
class DropDoneEvent extends DropEvent {
|
||||
final List<DropItem> files;
|
||||
|
||||
DropDoneEvent({
|
||||
required Offset location,
|
||||
required this.files,
|
||||
}) : super(location);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType($location, $files)';
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
abstract class PasteboardPlatform {
|
||||
Future<Uint8List?> get image;
|
||||
|
||||
Future<String?> get html;
|
||||
|
||||
Future<void> writeImage(Uint8List? image);
|
||||
|
||||
Future<List<String>> files();
|
||||
|
||||
Future<bool> writeFiles(List<String> files);
|
||||
|
||||
Future<String?> get text;
|
||||
|
||||
void writeText(String value);
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import 'dart:io';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'pasteboard_platform.dart';
|
||||
|
||||
const PasteboardPlatform pasteboard = PasteboardPlatformIO();
|
||||
|
||||
class PasteboardPlatformIO implements PasteboardPlatform {
|
||||
const PasteboardPlatformIO();
|
||||
|
||||
static const MethodChannel _channel = MethodChannel('pasteboard');
|
||||
|
||||
@override
|
||||
Future<List<String>> files() async {
|
||||
final files = await _channel.invokeMethod<List>('files');
|
||||
return files?.cast<String>() ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> get html async {
|
||||
if (Platform.isWindows || Platform.isAndroid) {
|
||||
return await _channel.invokeMethod<Object>('html') as String?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> get image async {
|
||||
final image = await _channel.invokeMethod<Object>('image');
|
||||
|
||||
if (image == null) {
|
||||
return null;
|
||||
}
|
||||
if (Platform.isMacOS ||
|
||||
Platform.isLinux ||
|
||||
Platform.isIOS ||
|
||||
Platform.isAndroid) {
|
||||
return image as Uint8List;
|
||||
} else if (Platform.isWindows) {
|
||||
final file = File(image as String);
|
||||
final bytes = await file.readAsBytes();
|
||||
await file.delete();
|
||||
return bytes;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> writeFiles(List<String> files) async {
|
||||
try {
|
||||
await _channel.invokeMethod<Object>('writeFiles', files);
|
||||
return true;
|
||||
} catch (error, stacktrace) {
|
||||
debugPrint('$error\n$stacktrace');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeImage(Uint8List? image) async {
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
if (Platform.isIOS || Platform.isMacOS || Platform.isAndroid) {
|
||||
await _channel.invokeMethod<void>('writeImage', image);
|
||||
} else if (Platform.isWindows) {
|
||||
final file = await File(_getTempFileName()).create();
|
||||
file.writeAsBytesSync(image);
|
||||
await _channel
|
||||
.invokeMethod<Object>('writeImage', {'fileName': file.path});
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> get text async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
return data?.text;
|
||||
}
|
||||
|
||||
@override
|
||||
void writeText(String value) {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
}
|
||||
}
|
||||
|
||||
String _getTempFileName() {
|
||||
final dir = Directory.systemTemp;
|
||||
String tempFileName;
|
||||
|
||||
const uuid = Uuid();
|
||||
|
||||
while (true) {
|
||||
tempFileName = p.join(dir.path, uuid.v1().toString());
|
||||
if (!File(tempFileName).existsSync()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return tempFileName;
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:web/web.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'pasteboard_platform.dart';
|
||||
|
||||
Future<String?> _readBlobAsText(Blob blob) async {
|
||||
final FileReader reader = FileReader();
|
||||
final future =
|
||||
reader.onLoadEnd.first.then((ProgressEvent event) => reader.result);
|
||||
|
||||
reader.readAsText(blob);
|
||||
|
||||
final res = await future;
|
||||
return res.toString();
|
||||
}
|
||||
|
||||
Clipboard get _clipboard => window.navigator.clipboard;
|
||||
|
||||
extension on Blob {
|
||||
Future<Uint8List?> _readAsUint8List() async {
|
||||
final buffer = await arrayBuffer().toDart;
|
||||
return buffer.toDart.asUint8List();
|
||||
}
|
||||
}
|
||||
|
||||
const PasteboardPlatform pasteboard = PasteboardPlatformWeb();
|
||||
|
||||
class PasteboardPlatformWeb implements PasteboardPlatform {
|
||||
const PasteboardPlatformWeb();
|
||||
|
||||
@override
|
||||
Future<List<String>> files() async => const [];
|
||||
|
||||
@override
|
||||
Future<String?> get html async {
|
||||
try {
|
||||
final clipboardItems = await _clipboard.read().toDart;
|
||||
for (var clipboardItem in clipboardItems.toDart) {
|
||||
if (clipboardItem.types.toDart.contains('text/html'.toJS)) {
|
||||
final blob = await clipboardItem.getType('text/html').toDart;
|
||||
return _readBlobAsText(blob);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> get image async {
|
||||
try {
|
||||
final clipboardItems = await _clipboard.read().toDart;
|
||||
for (var item in clipboardItems.toDart) {
|
||||
for (var type in item.types.toDart) {
|
||||
if (type.toDart.startsWith('image/')) {
|
||||
final blob = await item.getType(type.toDart).toDart;
|
||||
return blob._readAsUint8List();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error, stacktrace) {
|
||||
debugPrint('get image failed: $error $stacktrace');
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> writeFiles(List<String> files) async => false;
|
||||
|
||||
@override
|
||||
Future<void> writeImage(Uint8List? image) async {
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final blob = Blob([image.toJS].toJS, BlobPropertyBag(type: 'image/png'));
|
||||
window.navigator.clipboard.write([
|
||||
ClipboardItem({'image/png': blob}.jsify()! as JSObject)
|
||||
].toJS);
|
||||
} catch (e) {
|
||||
debugPrint('Error writing image to clipboard: $e');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> get text async {
|
||||
final data = await _clipboard.readText().toDart;
|
||||
return data.toDart;
|
||||
}
|
||||
|
||||
@override
|
||||
void writeText(String value) {
|
||||
final fakeElement = _createCopyFakeElement(value)..select();
|
||||
document.body?.append(fakeElement);
|
||||
_select(fakeElement);
|
||||
_copyCommand();
|
||||
fakeElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
void _select(HTMLTextAreaElement element) {
|
||||
final isReadOnly = element.hasAttribute('readonly');
|
||||
|
||||
if (!isReadOnly) {
|
||||
element.setAttribute('readonly', '');
|
||||
}
|
||||
|
||||
element
|
||||
..select()
|
||||
..setSelectionRange(0, element.value.length);
|
||||
|
||||
if (!isReadOnly) {
|
||||
element.removeAttribute('readonly');
|
||||
}
|
||||
}
|
||||
|
||||
/// https://github.com/zenorocha/clipboard.js/blob/master/src/common/create-fake-element.js
|
||||
HTMLTextAreaElement _createCopyFakeElement(String value) {
|
||||
final isRtl = document.documentElement?.getAttribute('dir') == 'rtl';
|
||||
final fakeElement = HTMLTextAreaElement()
|
||||
// Prevent zooming on iOS
|
||||
..style.fontSize = '12pt'
|
||||
// Reset box model
|
||||
..style.border = '0'
|
||||
..style.padding = '0'
|
||||
// Move element out of screen horizontally
|
||||
..style.position = 'absolute'
|
||||
..style.setProperty(isRtl ? 'right' : 'left', '-9999px');
|
||||
|
||||
// Move element to the same position vertically
|
||||
final yPosition = document.documentElement?.scrollTop.toInt() ?? 0;
|
||||
fakeElement
|
||||
..style.top = '${yPosition}px'
|
||||
..setAttribute('readonly', '')
|
||||
..value = value;
|
||||
return fakeElement;
|
||||
}
|
||||
|
||||
bool _copyCommand() {
|
||||
try {
|
||||
return document.execCommand('copy');
|
||||
} catch (error, stack) {
|
||||
window.alert('$error, $stack');
|
||||
// ignore
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Vendored
+85
@@ -0,0 +1,85 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fox_utils/vendor/src/drop_item.dart';
|
||||
|
||||
class WebDropItem {
|
||||
WebDropItem({
|
||||
required this.uri,
|
||||
required this.children,
|
||||
this.data,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.size,
|
||||
required this.relativePath,
|
||||
required this.lastModified,
|
||||
});
|
||||
|
||||
final String uri;
|
||||
final List<WebDropItem> children;
|
||||
final Uint8List? data;
|
||||
final String name;
|
||||
final String type;
|
||||
final int size;
|
||||
final String? relativePath;
|
||||
final DateTime lastModified;
|
||||
|
||||
factory WebDropItem.fromJson(Map<String, dynamic> json) => WebDropItem(
|
||||
uri: json['uri'],
|
||||
children: json['children'] == null
|
||||
? []
|
||||
: (json['children'] as List)
|
||||
.cast<Map>()
|
||||
.map((e) => WebDropItem.fromJson(e.cast<String, dynamic>()))
|
||||
.toList(),
|
||||
data: json['data'] != null ? Uint8List.fromList(json['data']) : null,
|
||||
name: json['name'],
|
||||
type: json['type'],
|
||||
size: json['size'],
|
||||
relativePath: json['relativePath'],
|
||||
lastModified: DateTime.fromMillisecondsSinceEpoch(json['lastModified']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'uri': uri,
|
||||
'children': children.map((e) => e.toJson()).toList(),
|
||||
'data': data?.toList(),
|
||||
'name': name,
|
||||
'type': type,
|
||||
'size': size,
|
||||
'relativePath': relativePath,
|
||||
'lastModified': lastModified.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
DropItem toDropItem() {
|
||||
if (type == 'directory') {
|
||||
return DropItemDirectory(
|
||||
uri,
|
||||
children.map((e) => e.toDropItem()).toList(),
|
||||
name: name,
|
||||
mimeType: type,
|
||||
length: size,
|
||||
lastModified: lastModified,
|
||||
bytes: data,
|
||||
);
|
||||
} else {
|
||||
if (data != null) {
|
||||
return DropItemFile.fromData(
|
||||
data!,
|
||||
name: name,
|
||||
mimeType: type,
|
||||
length: size,
|
||||
lastModified: lastModified,
|
||||
path: uri,
|
||||
);
|
||||
}
|
||||
return DropItemFile(
|
||||
uri,
|
||||
name: name,
|
||||
mimeType: type,
|
||||
length: size,
|
||||
lastModified: lastModified,
|
||||
bytes: data,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
set(PROJECT_NAME "fox_utils")
|
||||
project(${PROJECT_NAME} LANGUAGES CXX)
|
||||
|
||||
# This value is used when generating builds using this plugin, so it must
|
||||
# not be changed
|
||||
set(PLUGIN_NAME "fox_utils_plugin")
|
||||
|
||||
add_library(${PLUGIN_NAME} SHARED
|
||||
"fox_utils_plugin.cc"
|
||||
"pasteboard_plugin.cc"
|
||||
"desktop_drop_plugin.cc"
|
||||
)
|
||||
apply_standard_settings(${PLUGIN_NAME})
|
||||
set_target_properties(${PLUGIN_NAME} PROPERTIES
|
||||
CXX_VISIBILITY_PRESET hidden)
|
||||
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
|
||||
target_include_directories(${PLUGIN_NAME} INTERFACE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter)
|
||||
target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK)
|
||||
|
||||
# List of absolute paths to libraries that should be bundled with the plugin
|
||||
set(fox_utils_bundled_libraries
|
||||
""
|
||||
PARENT_SCOPE
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "include/fox_utils/desktop_drop_plugin.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#include <gtk/gtk.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/utsname.h>
|
||||
|
||||
#define DESKTOP_DROP_PLUGIN(obj) \
|
||||
(G_TYPE_CHECK_INSTANCE_CAST((obj), desktop_drop_plugin_get_type(), \
|
||||
DesktopDropPlugin))
|
||||
|
||||
struct _DesktopDropPlugin {
|
||||
GObject parent_instance;
|
||||
};
|
||||
|
||||
static gboolean isKDE = FALSE;
|
||||
static gboolean ignoreNext = FALSE;
|
||||
|
||||
G_DEFINE_TYPE(DesktopDropPlugin, desktop_drop_plugin, g_object_get_type())
|
||||
|
||||
void on_drag_data_received(GtkWidget *widget, GdkDragContext *drag_context,
|
||||
gint x, gint y, GtkSelectionData *sdata, guint info,
|
||||
guint time, gpointer user_data) {
|
||||
auto *channel = static_cast<FlMethodChannel *>(user_data);
|
||||
auto *data = gtk_selection_data_get_data(sdata);
|
||||
double point[] = {double(x), double(y)};
|
||||
auto args = fl_value_new_list();
|
||||
fl_value_append(args, fl_value_new_string((gchar *) data));
|
||||
fl_value_append(args, fl_value_new_float_list(point, 2));
|
||||
fl_method_channel_invoke_method(channel, "performOperation_linux", args,
|
||||
nullptr, nullptr, nullptr);
|
||||
}
|
||||
|
||||
void on_drag_motion(GtkWidget *widget, GdkDragContext *drag_context,
|
||||
gint x, gint y, guint time, gpointer user_data) {
|
||||
if (ignoreNext) {
|
||||
ignoreNext = FALSE;
|
||||
return;
|
||||
}
|
||||
|
||||
auto *channel = static_cast<FlMethodChannel *>(user_data);
|
||||
double point[] = {double(x), double(y)};
|
||||
g_autoptr(FlValue) args = fl_value_new_float_list(point, 2);
|
||||
fl_method_channel_invoke_method(channel, "updated", args,
|
||||
nullptr, nullptr, nullptr);
|
||||
}
|
||||
|
||||
void on_drag_leave(GtkWidget *widget, GdkDragContext *drag_context, guint time, gpointer user_data) {
|
||||
auto *channel = static_cast<FlMethodChannel *>(user_data);
|
||||
fl_method_channel_invoke_method(channel, "exited", nullptr,
|
||||
nullptr, nullptr, nullptr);
|
||||
}
|
||||
|
||||
// Called when a method call is received from Flutter.
|
||||
static void desktop_drop_plugin_handle_method_call(
|
||||
DesktopDropPlugin *self,
|
||||
FlMethodCall *method_call) {
|
||||
fl_method_call_respond_not_implemented(method_call, nullptr);
|
||||
}
|
||||
|
||||
static void desktop_drop_plugin_dispose(GObject *object) {
|
||||
G_OBJECT_CLASS(desktop_drop_plugin_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
static void desktop_drop_plugin_class_init(DesktopDropPluginClass *klass) {
|
||||
G_OBJECT_CLASS(klass)->dispose = desktop_drop_plugin_dispose;
|
||||
}
|
||||
|
||||
static void desktop_drop_plugin_init(DesktopDropPlugin *self) {
|
||||
const char * desktopEnv = getenv("XDG_CURRENT_DESKTOP");
|
||||
if (desktopEnv) {
|
||||
const char * lowercaseDesktopEnv = g_ascii_strdown(desktopEnv, -1);
|
||||
|
||||
if (strcmp(lowercaseDesktopEnv, "kde") == 0 || strcmp(lowercaseDesktopEnv, "plasma") == 0) {
|
||||
isKDE = TRUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static gboolean on_focus_in_event(GtkWidget *widget, GdkEventFocus *event, gpointer user_data) {
|
||||
if (isKDE) {
|
||||
ignoreNext = TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static void method_call_cb(FlMethodChannel *channel, FlMethodCall *method_call,
|
||||
gpointer user_data) {
|
||||
DesktopDropPlugin *plugin = DESKTOP_DROP_PLUGIN(user_data);
|
||||
desktop_drop_plugin_handle_method_call(plugin, method_call);
|
||||
}
|
||||
|
||||
void desktop_drop_plugin_register_with_registrar(FlPluginRegistrar *registrar) {
|
||||
DesktopDropPlugin *plugin = DESKTOP_DROP_PLUGIN(
|
||||
g_object_new(desktop_drop_plugin_get_type(), nullptr));
|
||||
|
||||
auto *fl_view = fl_plugin_registrar_get_view(registrar);
|
||||
static GtkTargetEntry entries[] = {
|
||||
{strdup("STRING"), GTK_TARGET_OTHER_APP, 0}
|
||||
};
|
||||
gtk_drag_dest_set(GTK_WIDGET(fl_view), GTK_DEST_DEFAULT_ALL, entries, 1, GDK_ACTION_COPY);
|
||||
gtk_drag_dest_add_uri_targets(GTK_WIDGET(fl_view));
|
||||
|
||||
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||
FlMethodChannel *channel =
|
||||
fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar),
|
||||
"desktop_drop",
|
||||
FL_METHOD_CODEC(codec));
|
||||
fl_method_channel_set_method_call_handler(channel, method_call_cb,
|
||||
g_object_ref(plugin),
|
||||
g_object_unref);
|
||||
|
||||
g_signal_connect(fl_view, "drag-motion",
|
||||
G_CALLBACK(on_drag_motion), channel);
|
||||
g_signal_connect(GTK_WIDGET(fl_view), "drag-data-received",
|
||||
G_CALLBACK(on_drag_data_received), channel);
|
||||
g_signal_connect(GTK_WIDGET(fl_view), "drag-leave",
|
||||
G_CALLBACK(on_drag_leave), channel);
|
||||
g_signal_connect(fl_view, "focus-in-event",
|
||||
G_CALLBACK(on_focus_in_event), nullptr);
|
||||
|
||||
g_object_unref(plugin);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "include/fox_utils/fox_utils_plugin.h"
|
||||
#include "include/fox_utils/pasteboard_plugin.h"
|
||||
#include "include/fox_utils/desktop_drop_plugin.h"
|
||||
|
||||
void fox_utils_plugin_register_with_registrar(FlPluginRegistrar* registrar) {
|
||||
pasteboard_plugin_register_with_registrar(registrar);
|
||||
desktop_drop_plugin_register_with_registrar(registrar);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#ifndef FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
|
||||
#define FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
#ifdef FLUTTER_PLUGIN_IMPL
|
||||
#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default")))
|
||||
#else
|
||||
#define FLUTTER_PLUGIN_EXPORT
|
||||
#endif
|
||||
|
||||
typedef struct _DesktopDropPlugin DesktopDropPlugin;
|
||||
typedef struct {
|
||||
GObjectClass parent_class;
|
||||
} DesktopDropPluginClass;
|
||||
|
||||
FLUTTER_PLUGIN_EXPORT GType desktop_drop_plugin_get_type();
|
||||
|
||||
FLUTTER_PLUGIN_EXPORT void desktop_drop_plugin_register_with_registrar(
|
||||
FlPluginRegistrar* registrar);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif // FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
|
||||
@@ -0,0 +1,22 @@
|
||||
#ifndef FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
|
||||
#define FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
#ifdef FLUTTER_PLUGIN_IMPL
|
||||
#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default")))
|
||||
#else
|
||||
#define FLUTTER_PLUGIN_EXPORT
|
||||
#endif
|
||||
|
||||
// Single native registration entry for the fox_utils package on Linux.
|
||||
// The generated plugin registrant calls this function; it in turn registers
|
||||
// every vendored engine in this package (pasteboard, desktop_drop).
|
||||
FLUTTER_PLUGIN_EXPORT void fox_utils_plugin_register_with_registrar(
|
||||
FlPluginRegistrar* registrar);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif // FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
|
||||
@@ -0,0 +1,26 @@
|
||||
#ifndef FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
|
||||
#define FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
#ifdef FLUTTER_PLUGIN_IMPL
|
||||
#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default")))
|
||||
#else
|
||||
#define FLUTTER_PLUGIN_EXPORT
|
||||
#endif
|
||||
|
||||
typedef struct _PasteboardPlugin PasteboardPlugin;
|
||||
typedef struct {
|
||||
GObjectClass parent_class;
|
||||
} PasteboardPluginClass;
|
||||
|
||||
FLUTTER_PLUGIN_EXPORT GType pasteboard_plugin_get_type();
|
||||
|
||||
FLUTTER_PLUGIN_EXPORT void pasteboard_plugin_register_with_registrar(
|
||||
FlPluginRegistrar* registrar);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif // FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
|
||||
@@ -0,0 +1,190 @@
|
||||
#include "include/fox_utils/pasteboard_plugin.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#define GNOME_COPIED_FILES gdk_atom_intern_static_string ("x-special/gnome-copied-files")
|
||||
|
||||
#define PASTEBOARD_PLUGIN(obj) \
|
||||
(G_TYPE_CHECK_INSTANCE_CAST((obj), pasteboard_plugin_get_type(), \
|
||||
PasteboardPlugin))
|
||||
|
||||
struct _PasteboardPlugin {
|
||||
GObject parent_instance;
|
||||
};
|
||||
|
||||
G_DEFINE_TYPE(PasteboardPlugin, pasteboard_plugin, g_object_get_type())
|
||||
|
||||
static void gtk_clipboard_request_uris_callback(
|
||||
GtkClipboard *clipboard,
|
||||
gchar **uris,
|
||||
gpointer user_data
|
||||
) {
|
||||
g_autoptr(FlMethodCall) method_call = static_cast<FlMethodCall *>(user_data);
|
||||
|
||||
g_autoptr(FlValue) result = fl_value_new_list();
|
||||
for (auto uri = uris; uri != nullptr && *uri != nullptr; uri++) {
|
||||
g_autoptr(GFile) file = g_file_new_for_uri(*uri);
|
||||
auto *file_path = g_file_get_path(file);
|
||||
if (file_path) {
|
||||
fl_value_append(result, fl_value_new_string(file_path));
|
||||
}
|
||||
}
|
||||
fl_method_call_respond_success(method_call, result, nullptr);
|
||||
}
|
||||
|
||||
static void gtk_clipboard_get_file_uri(GtkClipboard *clipboard,
|
||||
GtkSelectionData *selection_data,
|
||||
guint info,
|
||||
gpointer user_data_or_owner) {
|
||||
auto target = gtk_selection_data_get_target(selection_data);
|
||||
auto **uris = reinterpret_cast<gchar **>(user_data_or_owner);
|
||||
if (gtk_targets_include_uri(&target, 1)) {
|
||||
gtk_selection_data_set_uris(selection_data, reinterpret_cast<gchar **>(user_data_or_owner));
|
||||
} else if (gtk_targets_include_text(&target, 1)) {
|
||||
g_autoptr(GString) string = g_string_new(nullptr);
|
||||
bool should_insert_new_line = false;
|
||||
for (auto uri = uris; uri != nullptr && *uri != nullptr; uri++) {
|
||||
if (should_insert_new_line) {
|
||||
g_string_append_c(string, '\n');
|
||||
}
|
||||
g_string_append(string, *uri);
|
||||
should_insert_new_line = true;
|
||||
}
|
||||
gtk_selection_data_set_text(selection_data, string->str, int(string->len));
|
||||
} else if (target == GNOME_COPIED_FILES) {
|
||||
g_autoptr(GString) string = g_string_new("copy");
|
||||
for (auto uri = uris; uri != nullptr && *uri != nullptr; uri++) {
|
||||
g_string_append(string, "\n");
|
||||
g_string_append(string, *uri);
|
||||
}
|
||||
gtk_selection_data_set(selection_data, target, 8, (const guchar *) string->str, int(string->len));
|
||||
} else {
|
||||
g_critical("unsupported action: gdk_atom_name(target) = %s", gdk_atom_name(target));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static void gtk_clipboard_clear(GtkClipboard *clipboard,
|
||||
gpointer user_data_or_owner) {
|
||||
auto **uris = reinterpret_cast<gchar **>(user_data_or_owner);
|
||||
delete[] uris;
|
||||
}
|
||||
|
||||
static void clipboard_request_image_callback(
|
||||
GtkClipboard *clipboard,
|
||||
GdkPixbuf *pixbuf,
|
||||
gpointer user_data) {
|
||||
g_autoptr(FlMethodCall) method_call = static_cast<FlMethodCall *>(user_data);
|
||||
|
||||
if (!pixbuf) {
|
||||
fl_method_call_respond_success(method_call, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
gchar *buffer = nullptr;
|
||||
gsize buffer_size = 0;
|
||||
GError *error = nullptr;
|
||||
|
||||
gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &buffer_size, "png", &error, nullptr);
|
||||
if (error) {
|
||||
fl_method_call_respond_error(method_call, "0", error->message, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!buffer) {
|
||||
fl_method_call_respond_error(method_call, "0", "failed to get image", nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
fl_method_call_respond_success(method_call,
|
||||
fl_value_new_uint8_list(reinterpret_cast<const uint8_t *>(buffer), buffer_size),
|
||||
nullptr);
|
||||
|
||||
}
|
||||
|
||||
// Called when a method call is received from Flutter.
|
||||
static void pasteboard_plugin_handle_method_call(
|
||||
PasteboardPlugin *self,
|
||||
FlMethodCall *method_call) {
|
||||
const gchar *method = fl_method_call_get_name(method_call);
|
||||
if (strcmp(method, "files") == 0) {
|
||||
auto *clipboard = gtk_clipboard_get_default(gdk_display_get_default());
|
||||
gtk_clipboard_request_uris(clipboard, gtk_clipboard_request_uris_callback, g_object_ref(method_call));
|
||||
} else if (strcmp(method, "writeFiles") == 0) {
|
||||
auto *clipboard = gtk_clipboard_get_default(gdk_display_get_default());
|
||||
|
||||
auto args = fl_method_call_get_args(method_call);
|
||||
auto size = fl_value_get_length(args);
|
||||
|
||||
auto **paths = new gchar *[size + 1];
|
||||
paths[size] = nullptr;
|
||||
for (unsigned int i = 0; i < size; ++i) {
|
||||
auto path = fl_value_get_string(fl_value_get_list_value(args, i));
|
||||
paths[i] = g_strconcat("file://", path, nullptr);
|
||||
}
|
||||
|
||||
GtkTargetList *target_list = gtk_target_list_new(nullptr, 0);
|
||||
gtk_target_list_add(target_list, GNOME_COPIED_FILES, 0, 0);
|
||||
|
||||
gtk_target_list_add_uri_targets(target_list, 0);
|
||||
gtk_target_list_add_text_targets(target_list, 0);
|
||||
|
||||
int n_targets = 1;
|
||||
GtkTargetEntry *target_table = gtk_target_table_new_from_list(target_list, &n_targets);
|
||||
|
||||
gtk_clipboard_set_with_data(clipboard,
|
||||
target_table,
|
||||
n_targets,
|
||||
gtk_clipboard_get_file_uri,
|
||||
gtk_clipboard_clear,
|
||||
paths);
|
||||
|
||||
gtk_target_list_unref(target_list);
|
||||
gtk_target_table_free(target_table, n_targets);
|
||||
|
||||
fl_method_call_respond_success(method_call, nullptr, nullptr);
|
||||
} else if (strcmp(method, "image") == 0) {
|
||||
auto *clipboard = gtk_clipboard_get_default(gdk_display_get_default());
|
||||
|
||||
gtk_clipboard_request_image(clipboard, clipboard_request_image_callback, g_object_ref(method_call));
|
||||
|
||||
} else {
|
||||
fl_method_call_respond_not_implemented(method_call, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
static void pasteboard_plugin_dispose(GObject *object) {
|
||||
G_OBJECT_CLASS(pasteboard_plugin_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
static void pasteboard_plugin_class_init(PasteboardPluginClass *klass) {
|
||||
G_OBJECT_CLASS(klass)->dispose = pasteboard_plugin_dispose;
|
||||
}
|
||||
|
||||
static void pasteboard_plugin_init(PasteboardPlugin *self) {}
|
||||
|
||||
static void method_call_cb(FlMethodChannel *channel, FlMethodCall *method_call,
|
||||
gpointer user_data) {
|
||||
PasteboardPlugin *plugin = PASTEBOARD_PLUGIN(user_data);
|
||||
pasteboard_plugin_handle_method_call(plugin, method_call);
|
||||
}
|
||||
|
||||
void pasteboard_plugin_register_with_registrar(FlPluginRegistrar *registrar) {
|
||||
PasteboardPlugin *plugin = PASTEBOARD_PLUGIN(
|
||||
g_object_new(pasteboard_plugin_get_type(), nullptr));
|
||||
|
||||
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||
g_autoptr(FlMethodChannel) channel =
|
||||
fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar),
|
||||
"pasteboard",
|
||||
FL_METHOD_CODEC(codec));
|
||||
fl_method_channel_set_method_call_handler(channel, method_call_cb,
|
||||
g_object_ref(plugin),
|
||||
g_object_unref);
|
||||
|
||||
g_object_unref(plugin);
|
||||
}
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
name: fox_utils
|
||||
description: A collection of reusable Flutter widgets and extensions.
|
||||
version: 0.1.7
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_web_plugins:
|
||||
sdk: flutter
|
||||
cross_file: ^0.3.4+2 # XFile base of DropItem (vendored desktop_drop engine)
|
||||
dio: # Only non-vendored 3rd-party dependency
|
||||
path: ^1.9.0
|
||||
universal_platform: ^1.1.0 # web-safe platform checks (vendored desktop_drop engine)
|
||||
uuid: ^4.0.0
|
||||
web: ^1.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
android:
|
||||
package: com.foxyz.fox_utils
|
||||
pluginClass: FoxUtilsPlugin
|
||||
linux:
|
||||
pluginClass: FoxUtilsPlugin
|
||||
windows:
|
||||
pluginClass: FoxUtilsPlugin
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fox_utils/fox_utils.dart';
|
||||
|
||||
void main() {
|
||||
test('adds one to input values', () {
|
||||
final calculator = Calculator();
|
||||
expect(calculator.addOne(2), 3);
|
||||
expect(calculator.addOne(-7), -6);
|
||||
expect(calculator.addOne(0), 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
set(PROJECT_NAME "fox_utils")
|
||||
project(${PROJECT_NAME} LANGUAGES CXX)
|
||||
|
||||
# This value is used when generating builds using this plugin, so it must
|
||||
# not be changed
|
||||
set(PLUGIN_NAME "fox_utils_plugin")
|
||||
|
||||
add_library(${PLUGIN_NAME} SHARED
|
||||
"fox_utils_plugin.cpp"
|
||||
"pasteboard_plugin.cpp"
|
||||
"desktop_drop_plugin.cpp"
|
||||
)
|
||||
apply_standard_settings(${PLUGIN_NAME})
|
||||
set_target_properties(${PLUGIN_NAME} PROPERTIES
|
||||
CXX_VISIBILITY_PRESET hidden)
|
||||
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
|
||||
target_include_directories(${PLUGIN_NAME} INTERFACE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin)
|
||||
|
||||
# GdiPlus doesn't compile with /W4 warning level; strict flags are scoped to
|
||||
# this package's own sources. The vendored desktop_drop engine is compiled
|
||||
# as upstream ships it (no warnings-as-errors) since it is not ours to churn.
|
||||
set_source_files_properties("fox_utils_plugin.cpp" "pasteboard_plugin.cpp"
|
||||
PROPERTIES COMPILE_OPTIONS
|
||||
"$<$<CXX_COMPILER_ID:MSVC>:/W3 /WX>;$<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-Wall -Wextra -Wpedantic -Werror>")
|
||||
set_source_files_properties("desktop_drop_plugin.cpp"
|
||||
PROPERTIES COMPILE_OPTIONS
|
||||
"$<$<CXX_COMPILER_ID:MSVC>:/W3>")
|
||||
|
||||
# List of absolute paths to libraries that should be bundled with the plugin
|
||||
set(fox_utils_bundled_libraries
|
||||
""
|
||||
PARENT_SCOPE
|
||||
)
|
||||
@@ -0,0 +1,285 @@
|
||||
#include "include/fox_utils/desktop_drop_plugin.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/plugin_registrar_windows.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
|
||||
using FlutterMethodChannel = std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>>;
|
||||
|
||||
|
||||
using namespace std::literals::string_literals;
|
||||
|
||||
std::string ws2s(const std::wstring &wstr) {
|
||||
if (wstr.empty()) {
|
||||
return {};
|
||||
}
|
||||
size_t pos;
|
||||
size_t begin = 0;
|
||||
std::string ret;
|
||||
|
||||
int size;
|
||||
pos = wstr.find(static_cast<wchar_t>(0), begin);
|
||||
while (pos != std::wstring::npos && begin < wstr.length()) {
|
||||
std::wstring segment = std::wstring(&wstr[begin], pos - begin);
|
||||
size = WideCharToMultiByte(CP_UTF8,
|
||||
WC_ERR_INVALID_CHARS,
|
||||
&segment[0],
|
||||
(int) segment.size(),
|
||||
nullptr,
|
||||
0,
|
||||
nullptr,
|
||||
nullptr);
|
||||
std::string converted = std::string(size, 0);
|
||||
WideCharToMultiByte(CP_UTF8,
|
||||
WC_ERR_INVALID_CHARS,
|
||||
&segment[0],
|
||||
(int) segment.size(),
|
||||
&converted[0],
|
||||
(int) converted.size(),
|
||||
nullptr,
|
||||
nullptr);
|
||||
ret.append(converted);
|
||||
ret.append({0});
|
||||
begin = pos + 1;
|
||||
pos = wstr.find(static_cast<wchar_t>(0), begin);
|
||||
}
|
||||
if (begin <= wstr.length()) {
|
||||
std::wstring segment = std::wstring(&wstr[begin], wstr.length() - begin);
|
||||
size =
|
||||
WideCharToMultiByte(CP_UTF8,
|
||||
WC_ERR_INVALID_CHARS,
|
||||
&segment[0],
|
||||
(int) segment.size(),
|
||||
nullptr,
|
||||
0,
|
||||
nullptr,
|
||||
nullptr);
|
||||
std::string converted = std::string(size, 0);
|
||||
WideCharToMultiByte(CP_UTF8,
|
||||
WC_ERR_INVALID_CHARS,
|
||||
&segment[0],
|
||||
(int) segment.size(),
|
||||
&converted[0],
|
||||
(int) converted.size(),
|
||||
nullptr,
|
||||
nullptr);
|
||||
ret.append(converted);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
class DesktopDropTarget : public IDropTarget {
|
||||
public:
|
||||
|
||||
DesktopDropTarget(FlutterMethodChannel channel, HWND window_handle);
|
||||
|
||||
HRESULT DragEnter(IDataObject *pDataObj, DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) override;
|
||||
|
||||
HRESULT DragOver(DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) override;
|
||||
|
||||
HRESULT DragLeave() override;
|
||||
|
||||
HRESULT Drop(IDataObject *pDataObj, DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) override;
|
||||
|
||||
HRESULT QueryInterface(const IID &riid, void **ppvObject) override;
|
||||
|
||||
ULONG AddRef() override;
|
||||
|
||||
ULONG Release() override;
|
||||
|
||||
virtual ~DesktopDropTarget();
|
||||
|
||||
private:
|
||||
FlutterMethodChannel channel_;
|
||||
HWND window_handle_;
|
||||
LONG ref_count_;
|
||||
bool need_revoke_ole_initialize_;
|
||||
};
|
||||
|
||||
class DesktopDropPlugin : public flutter::Plugin {
|
||||
|
||||
public:
|
||||
static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar);
|
||||
|
||||
explicit DesktopDropPlugin(DesktopDropTarget *target);
|
||||
|
||||
~DesktopDropPlugin() override;
|
||||
|
||||
private:
|
||||
|
||||
DesktopDropTarget *target_;
|
||||
|
||||
};
|
||||
|
||||
DesktopDropPlugin::DesktopDropPlugin(DesktopDropTarget *target) : target_(target) {
|
||||
target_->AddRef();
|
||||
}
|
||||
|
||||
DesktopDropPlugin::~DesktopDropPlugin() {
|
||||
target_->Release();
|
||||
}
|
||||
|
||||
|
||||
// static
|
||||
void DesktopDropPlugin::RegisterWithRegistrar(
|
||||
flutter::PluginRegistrarWindows *registrar) {
|
||||
auto channel =
|
||||
std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||
registrar->messenger(), "desktop_drop",
|
||||
&flutter::StandardMethodCodec::GetInstance());
|
||||
|
||||
HWND hwnd = nullptr;
|
||||
if (registrar->GetView()) {
|
||||
hwnd = registrar->GetView()->GetNativeWindow();
|
||||
}
|
||||
|
||||
if (hwnd == nullptr) {
|
||||
// no window, no drop.
|
||||
return;
|
||||
}
|
||||
|
||||
channel->SetMethodCallHandler([](const auto &call, auto result) {
|
||||
result->NotImplemented();
|
||||
});
|
||||
|
||||
auto drop_target = new DesktopDropTarget(std::move(channel), hwnd);
|
||||
|
||||
auto plugin = std::make_unique<DesktopDropPlugin>(drop_target);
|
||||
|
||||
registrar->AddPlugin(std::move(plugin));
|
||||
}
|
||||
|
||||
|
||||
DesktopDropTarget::DesktopDropTarget(FlutterMethodChannel channel, HWND window_handle) : channel_(
|
||||
std::move(channel)), window_handle_(window_handle), ref_count_(0), need_revoke_ole_initialize_(false) {
|
||||
auto ret = RegisterDragDrop(window_handle_, this);
|
||||
if (ret == E_OUTOFMEMORY) {
|
||||
OleInitialize(nullptr);
|
||||
ret = RegisterDragDrop(window_handle_, this);
|
||||
if (ret == 0) {
|
||||
need_revoke_ole_initialize_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (ret != 0) {
|
||||
std::cout << "RegisterDragDrop failed: " << ret << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
HRESULT DesktopDropTarget::DragEnter(IDataObject *pDataObj, DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) {
|
||||
POINT point = {pt.x, pt.y};
|
||||
ScreenToClient(window_handle_, &point);
|
||||
channel_->InvokeMethod("entered", std::make_unique<flutter::EncodableValue>(
|
||||
flutter::EncodableList{
|
||||
flutter::EncodableValue(double(point.x)),
|
||||
flutter::EncodableValue(double(point.y))
|
||||
}
|
||||
));
|
||||
return 0;
|
||||
}
|
||||
|
||||
HRESULT DesktopDropTarget::DragOver(DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) {
|
||||
POINT point = {pt.x, pt.y};
|
||||
ScreenToClient(window_handle_, &point);
|
||||
channel_->InvokeMethod("updated", std::make_unique<flutter::EncodableValue>(
|
||||
flutter::EncodableList{
|
||||
flutter::EncodableValue(double(point.x)),
|
||||
flutter::EncodableValue(double(point.y))
|
||||
}
|
||||
));
|
||||
return 0;
|
||||
}
|
||||
|
||||
HRESULT DesktopDropTarget::DragLeave() {
|
||||
channel_->InvokeMethod("exited", std::make_unique<flutter::EncodableValue>());
|
||||
return 0;
|
||||
}
|
||||
|
||||
HRESULT DesktopDropTarget::Drop(IDataObject *pDataObj, DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) {
|
||||
|
||||
flutter::EncodableList list = {};
|
||||
|
||||
// construct a FORMATETC object
|
||||
FORMATETC fmtetc = {CF_HDROP, nullptr, DVASPECT_CONTENT, -1, TYMED_HGLOBAL};
|
||||
STGMEDIUM stgmed;
|
||||
|
||||
// See if the dataobject contains any TEXT stored as a HGLOBAL
|
||||
if (pDataObj->QueryGetData(&fmtetc) == S_OK) {
|
||||
// Yippie! the data is there, so go get it!
|
||||
if (pDataObj->GetData(&fmtetc, &stgmed) == S_OK) {
|
||||
// we asked for the data as a HGLOBAL, so access it appropriately
|
||||
PVOID data = GlobalLock(stgmed.hGlobal);
|
||||
if (data != nullptr) {
|
||||
auto files = DragQueryFile(reinterpret_cast<HDROP>(data), 0xFFFFFFFF, nullptr, 0);
|
||||
for (unsigned int i = 0; i < files; ++i) {
|
||||
TCHAR filename[MAX_PATH];
|
||||
DragQueryFile(reinterpret_cast<HDROP>(data), i, filename, sizeof(TCHAR) * MAX_PATH);
|
||||
std::wstring wide(filename);
|
||||
std::string path = ws2s(wide);
|
||||
std::cout << "done: " << path << std::endl;
|
||||
list.push_back(flutter::EncodableValue(path));
|
||||
}
|
||||
GlobalUnlock(stgmed.hGlobal);
|
||||
}
|
||||
|
||||
// release the data using the COM API
|
||||
ReleaseStgMedium(&stgmed);
|
||||
}
|
||||
}
|
||||
channel_->InvokeMethod("performOperation", std::make_unique<flutter::EncodableValue>(list));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
HRESULT DesktopDropTarget::QueryInterface(const IID &iid, void **ppvObject) {
|
||||
if (iid == IID_IDropTarget || iid == IID_IUnknown) {
|
||||
AddRef();
|
||||
*ppvObject = this;
|
||||
return S_OK;
|
||||
} else {
|
||||
*ppvObject = nullptr;
|
||||
return E_NOINTERFACE;
|
||||
}
|
||||
}
|
||||
|
||||
ULONG DesktopDropTarget::AddRef() {
|
||||
return InterlockedIncrement(&ref_count_);
|
||||
}
|
||||
|
||||
ULONG DesktopDropTarget::Release() {
|
||||
LONG count = InterlockedDecrement(&ref_count_);
|
||||
|
||||
if (count == 0) {
|
||||
delete this;
|
||||
return 0;
|
||||
} else {
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
DesktopDropTarget::~DesktopDropTarget() {
|
||||
RevokeDragDrop(window_handle_);
|
||||
if (need_revoke_ole_initialize_) {
|
||||
OleUninitialize();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void DesktopDropPluginRegisterWithRegistrar(
|
||||
FlutterDesktopPluginRegistrarRef registrar) {
|
||||
DesktopDropPlugin::RegisterWithRegistrar(
|
||||
flutter::PluginRegistrarManager::GetInstance()
|
||||
->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#include "include/fox_utils/fox_utils_plugin.h"
|
||||
#include "include/fox_utils/pasteboard_plugin.h"
|
||||
#include "include/fox_utils/desktop_drop_plugin.h"
|
||||
|
||||
void FoxUtilsPluginRegisterWithRegistrar(
|
||||
FlutterDesktopPluginRegistrarRef registrar) {
|
||||
PasteboardPluginRegisterWithRegistrar(registrar);
|
||||
DesktopDropPluginRegisterWithRegistrar(registrar);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#ifndef FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
|
||||
#define FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
|
||||
|
||||
#include <flutter_plugin_registrar.h>
|
||||
|
||||
#ifdef FLUTTER_PLUGIN_IMPL
|
||||
#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport)
|
||||
#else
|
||||
#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport)
|
||||
#endif
|
||||
|
||||
#if defined(__cplusplus)
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
FLUTTER_PLUGIN_EXPORT void DesktopDropPluginRegisterWithRegistrar(
|
||||
FlutterDesktopPluginRegistrarRef registrar);
|
||||
|
||||
#if defined(__cplusplus)
|
||||
} // extern "C"
|
||||
#endif
|
||||
|
||||
#endif // FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
|
||||
@@ -0,0 +1,26 @@
|
||||
#ifndef FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
|
||||
#define FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
|
||||
|
||||
#include <flutter_plugin_registrar.h>
|
||||
|
||||
#ifdef FLUTTER_PLUGIN_IMPL
|
||||
#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport)
|
||||
#else
|
||||
#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport)
|
||||
#endif
|
||||
|
||||
#if defined(__cplusplus)
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Single native registration entry for the fox_utils package on Windows.
|
||||
// The generated plugin registrant calls this function; it in turn registers
|
||||
// every vendored engine in this package (pasteboard, desktop_drop).
|
||||
FLUTTER_PLUGIN_EXPORT void FoxUtilsPluginRegisterWithRegistrar(
|
||||
FlutterDesktopPluginRegistrarRef registrar);
|
||||
|
||||
#if defined(__cplusplus)
|
||||
} // extern "C"
|
||||
#endif
|
||||
|
||||
#endif // FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
|
||||
@@ -0,0 +1,23 @@
|
||||
#ifndef FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
|
||||
#define FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
|
||||
|
||||
#include <flutter_plugin_registrar.h>
|
||||
|
||||
#ifdef FLUTTER_PLUGIN_IMPL
|
||||
#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport)
|
||||
#else
|
||||
#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport)
|
||||
#endif
|
||||
|
||||
#if defined(__cplusplus)
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
FLUTTER_PLUGIN_EXPORT void PasteboardPluginRegisterWithRegistrar(
|
||||
FlutterDesktopPluginRegistrarRef registrar);
|
||||
|
||||
#if defined(__cplusplus)
|
||||
} // extern "C"
|
||||
#endif
|
||||
|
||||
#endif // FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
|
||||
@@ -0,0 +1,555 @@
|
||||
#include "include/fox_utils/pasteboard_plugin.h"
|
||||
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/plugin_registrar_windows.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
|
||||
#include <Windows.h>
|
||||
#include <ShlObj.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
|
||||
#include "strconv.h"
|
||||
|
||||
#pragma comment(lib, "GdiPlus")
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr STGMEDIUM kNullStorageMedium = {TYMED_NULL, nullptr, nullptr};
|
||||
|
||||
STGMEDIUM CreateStorageForFileNames(const std::vector<std::string> &filenames) {
|
||||
// CF_HDROP clipboard format consists of DROPFILES structure, a series of file
|
||||
// names including the terminating null character and the additional null
|
||||
// character at the tail to terminate the array.
|
||||
// For example,
|
||||
//| DROPFILES | FILENAME 1 | NULL | ... | FILENAME n | NULL | NULL |
|
||||
// For more details, please refer to
|
||||
// https://docs.microsoft.com/en-us/windows/desktop/shell/clipboard#cf_hdrop
|
||||
|
||||
if (filenames.empty())
|
||||
return kNullStorageMedium;
|
||||
|
||||
const size_t kDropFilesHeaderSizeInBytes = sizeof(DROPFILES);
|
||||
size_t total_bytes = kDropFilesHeaderSizeInBytes;
|
||||
for (const auto &filename: filenames) {
|
||||
// Allocate memory of the filename's length including the null
|
||||
// character.
|
||||
total_bytes += (filename.length() + 1) * sizeof(wchar_t);
|
||||
}
|
||||
// |data| needs to be terminated by an additional null character.
|
||||
total_bytes += sizeof(wchar_t);
|
||||
|
||||
// GHND combines GMEM_MOVEABLE and GMEM_ZEROINIT, and GMEM_ZEROINIT
|
||||
// initializes memory contents to zero.
|
||||
HANDLE hdata = GlobalAlloc(GHND, total_bytes);
|
||||
|
||||
auto *drop_files = (DROPFILES *) GlobalLock(hdata);
|
||||
drop_files->pFiles = sizeof(DROPFILES);
|
||||
drop_files->fWide = TRUE;
|
||||
|
||||
auto *data = reinterpret_cast<wchar_t *>(
|
||||
reinterpret_cast<BYTE *>(drop_files) + kDropFilesHeaderSizeInBytes);
|
||||
|
||||
size_t next_filename_offset = 0;
|
||||
for (const auto &filename: filenames) {
|
||||
auto wide_filename = utf8_to_wide(filename);
|
||||
wcsncpy_s(data + next_filename_offset,
|
||||
wide_filename.length() + 1,
|
||||
wide_filename.c_str(),
|
||||
wide_filename.length() + 1);
|
||||
// Skip the terminating null character of the filename.
|
||||
next_filename_offset += wide_filename.length() + 1;
|
||||
}
|
||||
|
||||
STGMEDIUM storage;
|
||||
storage.tymed = TYMED_HGLOBAL;
|
||||
storage.hGlobal = hdata;
|
||||
storage.pUnkForRelease = nullptr;
|
||||
|
||||
GlobalUnlock(hdata);
|
||||
return storage;
|
||||
}
|
||||
|
||||
PBITMAPINFO CreateBitmapInfoStruct(HBITMAP hBmp) {
|
||||
BITMAP bmp;
|
||||
PBITMAPINFO pbmi;
|
||||
WORD cClrBits;
|
||||
|
||||
// Retrieve the bitmap color format, width, and height.
|
||||
GetObject(hBmp, sizeof(BITMAP), (LPSTR) &bmp);
|
||||
|
||||
// Convert the color format to a count of bits.
|
||||
cClrBits = (WORD) (bmp.bmPlanes * bmp.bmBitsPixel);
|
||||
if (cClrBits == 1)
|
||||
cClrBits = 1;
|
||||
else if (cClrBits <= 4)
|
||||
cClrBits = 4;
|
||||
else if (cClrBits <= 8)
|
||||
cClrBits = 8;
|
||||
else if (cClrBits <= 16)
|
||||
cClrBits = 16;
|
||||
else if (cClrBits <= 24)
|
||||
cClrBits = 24;
|
||||
else
|
||||
cClrBits = 32;
|
||||
|
||||
// Allocate memory for the BITMAPINFO structure. (This structure
|
||||
// contains a BITMAPINFOHEADER structure and an array of RGBQUAD
|
||||
// data structures.)
|
||||
|
||||
if (cClrBits < 24)
|
||||
pbmi = (PBITMAPINFO) LocalAlloc(
|
||||
LPTR, sizeof(BITMAPINFOHEADER) + sizeof(RGBQUAD) * int(1 << cClrBits));
|
||||
|
||||
// There is no RGBQUAD array for these formats: 24-bit-per-pixel or
|
||||
// 32-bit-per-pixel
|
||||
|
||||
else
|
||||
pbmi = (PBITMAPINFO) LocalAlloc(LPTR, sizeof(BITMAPINFOHEADER));
|
||||
|
||||
// Initialize the fields in the BITMAPINFO structure.
|
||||
|
||||
pbmi->bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
pbmi->bmiHeader.biWidth = bmp.bmWidth;
|
||||
pbmi->bmiHeader.biHeight = bmp.bmHeight;
|
||||
pbmi->bmiHeader.biPlanes = bmp.bmPlanes;
|
||||
pbmi->bmiHeader.biBitCount = bmp.bmBitsPixel;
|
||||
if (cClrBits < 24) pbmi->bmiHeader.biClrUsed = (1 << cClrBits);
|
||||
|
||||
// If the bitmap is not compressed, set the BI_RGB flag.
|
||||
pbmi->bmiHeader.biCompression = BI_RGB;
|
||||
|
||||
// Compute the number of bytes in the array of color
|
||||
// indices and store the result in biSizeImage.
|
||||
// The width must be DWORD aligned unless the bitmap is RLE
|
||||
// compressed.
|
||||
pbmi->bmiHeader.biSizeImage =
|
||||
((pbmi->bmiHeader.biWidth * cClrBits + 31) & ~31) / 8 *
|
||||
pbmi->bmiHeader.biHeight;
|
||||
// Set biClrImportant to 0, indicating that all of the
|
||||
// device colors are important.
|
||||
pbmi->bmiHeader.biClrImportant = 0;
|
||||
return pbmi;
|
||||
}
|
||||
|
||||
// The code was taken from this answer: https://stackoverflow.com/a/39201008/2134488
|
||||
//
|
||||
bool CopyImageToClipboard(const wchar_t* filename)
|
||||
{
|
||||
//initialize Gdiplus once:
|
||||
Gdiplus::GdiplusStartupInput gdiplusStartupInput;
|
||||
ULONG_PTR gdiplusToken;
|
||||
Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, NULL);
|
||||
|
||||
bool result = false;
|
||||
Gdiplus::Bitmap *gdibmp = Gdiplus::Bitmap::FromFile(filename);
|
||||
if (gdibmp)
|
||||
{
|
||||
HBITMAP hbitmap;
|
||||
gdibmp->GetHBITMAP(0, &hbitmap);
|
||||
if (OpenClipboard(NULL))
|
||||
{
|
||||
EmptyClipboard();
|
||||
DIBSECTION ds;
|
||||
if (GetObject(hbitmap, sizeof(DIBSECTION), &ds))
|
||||
{
|
||||
HDC hdc = GetDC(HWND_DESKTOP);
|
||||
//create compatible bitmap (get DDB from DIB)
|
||||
HBITMAP hbitmap_ddb = CreateDIBitmap(hdc, &ds.dsBmih, CBM_INIT,
|
||||
ds.dsBm.bmBits, (BITMAPINFO*)&ds.dsBmih, DIB_RGB_COLORS);
|
||||
ReleaseDC(HWND_DESKTOP, hdc);
|
||||
SetClipboardData(CF_BITMAP, hbitmap_ddb);
|
||||
DeleteObject(hbitmap_ddb);
|
||||
result = true;
|
||||
}
|
||||
CloseClipboard();
|
||||
}
|
||||
|
||||
//cleanup:
|
||||
DeleteObject(hbitmap);
|
||||
delete gdibmp;
|
||||
}
|
||||
|
||||
Gdiplus::GdiplusShutdown(gdiplusToken);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void CreateBMPFile(LPCTSTR pszFile, HBITMAP hBMP) {
|
||||
HANDLE hf; // file handle
|
||||
BITMAPFILEHEADER hdr; // bitmap file-header
|
||||
PBITMAPINFOHEADER pbih; // bitmap info-header
|
||||
LPBYTE lpBits; // memory pointer
|
||||
DWORD cb; // incremental count of bytes
|
||||
BYTE *hp; // byte pointer
|
||||
DWORD dwTmp;
|
||||
PBITMAPINFO pbi;
|
||||
HDC hDC;
|
||||
|
||||
hDC = CreateCompatibleDC(GetWindowDC(GetDesktopWindow()));
|
||||
SelectObject(hDC, hBMP);
|
||||
|
||||
pbi = CreateBitmapInfoStruct(hBMP);
|
||||
|
||||
pbih = (PBITMAPINFOHEADER) pbi;
|
||||
lpBits = (LPBYTE) GlobalAlloc(GMEM_FIXED, pbih->biSizeImage);
|
||||
|
||||
assert(lpBits);
|
||||
|
||||
// Retrieve the color table (RGBQUAD array) and the bits
|
||||
// (array of palette indices) from the DIB.
|
||||
GetDIBits(hDC, hBMP, 0, (WORD) pbih->biHeight, lpBits, pbi,
|
||||
DIB_RGB_COLORS);
|
||||
|
||||
// Create the .BMP file.
|
||||
hf = CreateFile(pszFile, GENERIC_READ | GENERIC_WRITE, (DWORD) 0, nullptr,
|
||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
assert(hf != INVALID_HANDLE_VALUE);
|
||||
|
||||
hdr.bfType = 0x4d42; // 0x42 = "B" 0x4d = "M"
|
||||
// Compute the size of the entire file.
|
||||
hdr.bfSize = (DWORD) (sizeof(BITMAPFILEHEADER) + pbih->biSize +
|
||||
pbih->biClrUsed * sizeof(RGBQUAD) + pbih->biSizeImage);
|
||||
hdr.bfReserved1 = 0;
|
||||
hdr.bfReserved2 = 0;
|
||||
|
||||
// Compute the offset to the array of color indices.
|
||||
hdr.bfOffBits = (DWORD) sizeof(BITMAPFILEHEADER) + pbih->biSize +
|
||||
pbih->biClrUsed * sizeof(RGBQUAD);
|
||||
|
||||
// Copy the BITMAPFILEHEADER into the .BMP file.
|
||||
WriteFile(hf, (LPVOID) &hdr, sizeof(BITMAPFILEHEADER), (LPDWORD) &dwTmp,
|
||||
nullptr);
|
||||
|
||||
// Copy the BITMAPINFOHEADER and RGBQUAD array into the file.
|
||||
WriteFile(hf, (LPVOID) pbih,
|
||||
sizeof(BITMAPINFOHEADER) + pbih->biClrUsed * sizeof(RGBQUAD),
|
||||
(LPDWORD) &dwTmp, (nullptr));
|
||||
|
||||
// Copy the array of color indices into the .BMP file.
|
||||
cb = pbih->biSizeImage;
|
||||
hp = lpBits;
|
||||
WriteFile(hf, (LPSTR) hp, (int) cb, (LPDWORD) &dwTmp, nullptr);
|
||||
|
||||
// Close the .BMP file.
|
||||
CloseHandle(hf);
|
||||
|
||||
// Free memory.
|
||||
GlobalFree((HGLOBAL) lpBits);
|
||||
}
|
||||
|
||||
void CreateBitmapHeaderWithColorDepth(LONG width, LONG height, WORD color_depth,
|
||||
BITMAPINFOHEADER *hdr) {
|
||||
// These values are shared with gfx::PlatformDevice.
|
||||
hdr->biSize = sizeof(BITMAPINFOHEADER);
|
||||
hdr->biWidth = width;
|
||||
hdr->biHeight = -height; // Minus means top-down bitmap.
|
||||
hdr->biPlanes = 1;
|
||||
hdr->biBitCount = color_depth;
|
||||
hdr->biCompression = BI_RGB; // No compression.
|
||||
hdr->biSizeImage = 0;
|
||||
hdr->biXPelsPerMeter = 1;
|
||||
hdr->biYPelsPerMeter = 1;
|
||||
hdr->biClrUsed = 0;
|
||||
hdr->biClrImportant = 0;
|
||||
}
|
||||
|
||||
HBITMAP CreateHBitmapXRGB8888(int width, int height, HANDLE shared_section,
|
||||
void **data) {
|
||||
if (width == 0 || height == 0) {
|
||||
width = 1;
|
||||
height = 1;
|
||||
}
|
||||
BITMAPINFOHEADER hdr = {0};
|
||||
CreateBitmapHeaderWithColorDepth(width, height, 32, &hdr);
|
||||
HBITMAP hbitmap =
|
||||
CreateDIBSection(nullptr, reinterpret_cast<const BITMAPINFO *>(&hdr), 0,
|
||||
data, shared_section, 0);
|
||||
return hbitmap;
|
||||
}
|
||||
|
||||
// A scoper to manage acquiring and automatically releasing the clipboard.
|
||||
class ScopedClipboard {
|
||||
public:
|
||||
ScopedClipboard() : opened_(false) {}
|
||||
|
||||
~ScopedClipboard() {
|
||||
if (opened_) Release();
|
||||
}
|
||||
|
||||
bool Acquire(HWND owner) {
|
||||
const int kMaxAttemptsToOpenClipboard = 5;
|
||||
|
||||
if (opened_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int attempts = 0; attempts < kMaxAttemptsToOpenClipboard; ++attempts) {
|
||||
if (::OpenClipboard(owner)) {
|
||||
opened_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we didn't manage to open the clipboard, sleep a bit and be hopeful.
|
||||
::Sleep(5);
|
||||
}
|
||||
|
||||
// We failed to acquire the clipboard.
|
||||
return false;
|
||||
}
|
||||
|
||||
void Release() {
|
||||
if (opened_) {
|
||||
::CloseClipboard();
|
||||
opened_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
bool opened_;
|
||||
};
|
||||
|
||||
class PasteboardPlugin : public flutter::Plugin {
|
||||
public:
|
||||
static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar);
|
||||
|
||||
PasteboardPlugin();
|
||||
|
||||
virtual ~PasteboardPlugin();
|
||||
|
||||
private:
|
||||
// Called when a method is called on this plugin's channel from Dart.
|
||||
void HandleMethodCall(
|
||||
const flutter::MethodCall<flutter::EncodableValue> &method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||
};
|
||||
|
||||
// static
|
||||
void PasteboardPlugin::RegisterWithRegistrar(
|
||||
flutter::PluginRegistrarWindows *registrar) {
|
||||
auto channel =
|
||||
std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||
registrar->messenger(), "pasteboard",
|
||||
&flutter::StandardMethodCodec::GetInstance());
|
||||
|
||||
auto plugin = std::make_unique<PasteboardPlugin>();
|
||||
|
||||
channel->SetMethodCallHandler(
|
||||
[plugin_pointer = plugin.get()](const auto &call, auto result) {
|
||||
plugin_pointer->HandleMethodCall(call, std::move(result));
|
||||
});
|
||||
|
||||
registrar->AddPlugin(std::move(plugin));
|
||||
}
|
||||
|
||||
PasteboardPlugin::PasteboardPlugin() {}
|
||||
|
||||
PasteboardPlugin::~PasteboardPlugin() {}
|
||||
|
||||
void PasteboardPlugin::HandleMethodCall(
|
||||
const flutter::MethodCall<flutter::EncodableValue> &method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
if (method_call.method_name() == "image") {
|
||||
if (!IsClipboardFormatAvailable(CF_DIB)) {
|
||||
result->Success();
|
||||
return;
|
||||
}
|
||||
ScopedClipboard clipboard;
|
||||
|
||||
if (!clipboard.Acquire(nullptr)) {
|
||||
result->Error("0", "open clipboard failed");
|
||||
return;
|
||||
}
|
||||
// We use a DIB rather than a DDB here since ::GetObject() with the
|
||||
// HBITMAP returned from ::GetClipboardData(CF_BITMAP) always reports a
|
||||
// color depth of 32bpp.
|
||||
auto *bitmap = static_cast<BITMAPINFO *>(::GetClipboardData(CF_DIB));
|
||||
if (!bitmap) {
|
||||
result->Success();
|
||||
return;
|
||||
}
|
||||
|
||||
int color_table_length = 0;
|
||||
|
||||
// For more information on BITMAPINFOHEADER and biBitCount definition,
|
||||
// see https://docs.microsoft.com/en-us/windows/win32/wmdm/-bitmapinfoheader
|
||||
switch (bitmap->bmiHeader.biBitCount) {
|
||||
case 1:
|
||||
case 4:
|
||||
case 8:
|
||||
color_table_length = bitmap->bmiHeader.biClrUsed
|
||||
? int(bitmap->bmiHeader.biClrUsed)
|
||||
: int(1 << bitmap->bmiHeader.biBitCount);
|
||||
break;
|
||||
case 16:
|
||||
case 32:
|
||||
if (bitmap->bmiHeader.biCompression == BI_BITFIELDS)
|
||||
color_table_length = 3;
|
||||
break;
|
||||
case 24:break;
|
||||
default:result->Success();
|
||||
return;
|
||||
}
|
||||
|
||||
const void *bitmap_bits = reinterpret_cast<const char *>(bitmap) +
|
||||
bitmap->bmiHeader.biSize +
|
||||
color_table_length * sizeof(RGBQUAD);
|
||||
|
||||
void *dst_bits;
|
||||
auto dst_hbitmap =
|
||||
CreateHBitmapXRGB8888(bitmap->bmiHeader.biWidth,
|
||||
bitmap->bmiHeader.biHeight, nullptr, &dst_bits);
|
||||
|
||||
auto hdc = CreateCompatibleDC(nullptr);
|
||||
auto old_hbitmap = static_cast<HBITMAP>(SelectObject(hdc, dst_hbitmap));
|
||||
::SetDIBitsToDevice(
|
||||
hdc, 0, 0, bitmap->bmiHeader.biWidth, bitmap->bmiHeader.biHeight, 0, 0,
|
||||
0, bitmap->bmiHeader.biHeight, bitmap_bits, bitmap, DIB_RGB_COLORS);
|
||||
SelectObject(hdc, old_hbitmap);
|
||||
DeleteDC(hdc);
|
||||
|
||||
TCHAR path[MAX_PATH];
|
||||
GetTempPath(MAX_PATH, path);
|
||||
TCHAR name[MAX_PATH];
|
||||
GetTempFileName(path, L"pasteboard", false, name);
|
||||
CreateBMPFile(name, dst_hbitmap);
|
||||
|
||||
DeleteObject(dst_hbitmap);
|
||||
|
||||
result->Success(flutter::EncodableValue(wide_to_utf8(name)));
|
||||
} else if (method_call.method_name() == "files") {
|
||||
if (!IsClipboardFormatAvailable(CF_HDROP)) {
|
||||
result->Success();
|
||||
return;
|
||||
}
|
||||
if (!OpenClipboard(nullptr)) {
|
||||
result->Error("0", "open clipboard failed");
|
||||
return;
|
||||
}
|
||||
auto handle = GetClipboardData(CF_HDROP);
|
||||
flutter::EncodableList file_list;
|
||||
if (handle) {
|
||||
auto data = reinterpret_cast<HDROP>(GlobalLock(handle));
|
||||
if (data) {
|
||||
auto files = DragQueryFile(data, 0xFFFFFFFF, nullptr, 0);
|
||||
for (unsigned int i = 0; i < files; ++i) {
|
||||
TCHAR filename[MAX_PATH];
|
||||
DragQueryFile(data, i, filename, sizeof(TCHAR) * MAX_PATH);
|
||||
std::wstring wide_filename(filename);
|
||||
file_list.emplace_back(wide_to_utf8(wide_filename));
|
||||
}
|
||||
}
|
||||
}
|
||||
CloseClipboard();
|
||||
result->Success(flutter::EncodableValue(file_list));
|
||||
} else if (method_call.method_name() == "writeFiles") {
|
||||
auto *arguments = method_call.arguments();
|
||||
auto files = std::get_if<std::vector<flutter::EncodableValue>>(arguments);
|
||||
if (!files) {
|
||||
result->Error("0", "files is empty");
|
||||
return;
|
||||
}
|
||||
std::vector<std::string> paths;
|
||||
for (const auto &item: *files) {
|
||||
if (std::holds_alternative<std::string>(item)) {
|
||||
paths.push_back(std::get<std::string>(item));
|
||||
}
|
||||
}
|
||||
|
||||
if (paths.empty()) {
|
||||
result->Error("0", "files is empty");
|
||||
return;
|
||||
}
|
||||
|
||||
ScopedClipboard clipboard;
|
||||
if (!clipboard.Acquire(nullptr)) {
|
||||
result->Error("0", "failed to open clipboard");
|
||||
return;
|
||||
}
|
||||
|
||||
auto storage = CreateStorageForFileNames(paths);
|
||||
if (storage.tymed == TYMED_NULL) {
|
||||
result->Error("0", "create storage failed");
|
||||
return;
|
||||
}
|
||||
SetClipboardData(CF_HDROP, storage.hGlobal);
|
||||
result->Success();
|
||||
}
|
||||
else if (method_call.method_name() == "writeImage") {
|
||||
|
||||
|
||||
const auto* arguments = std::get_if<flutter::EncodableMap>(method_call.arguments());
|
||||
|
||||
std::string fileName;
|
||||
if (arguments) {
|
||||
auto it = arguments->find(flutter::EncodableValue("fileName"));
|
||||
if (it != arguments->end()) {
|
||||
if (std::holds_alternative<std::string>(it->second)) {
|
||||
fileName = std::get<std::string>(it->second);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileName.size() == 0) {
|
||||
result->Error("0", "File name is empty");
|
||||
return;
|
||||
}
|
||||
std::wstring wsFileName = cp_to_wide(fileName, CP_UTF8);
|
||||
|
||||
if (!CopyImageToClipboard(wsFileName.c_str())) {
|
||||
result->Error("0", "Failed to copy image to clipboard");
|
||||
return;
|
||||
}
|
||||
|
||||
result->Success();
|
||||
}
|
||||
else if (method_call.method_name() == "html") {
|
||||
|
||||
UINT CF_HTML = RegisterClipboardFormatA("HTML Format");
|
||||
bool isHTMLFormatAvailable = false;
|
||||
|
||||
if (IsClipboardFormatAvailable(CF_HTML)) {
|
||||
isHTMLFormatAvailable = true;
|
||||
}
|
||||
|
||||
if (!isHTMLFormatAvailable) {
|
||||
result->Success();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!OpenClipboard(nullptr)) {
|
||||
result->Error("0", "open clipboard failed");
|
||||
return;
|
||||
}
|
||||
HANDLE hClipboardData = GetClipboardData(CF_HTML);
|
||||
if (hClipboardData == NULL) {
|
||||
result->Success();
|
||||
CloseClipboard();
|
||||
return;
|
||||
}
|
||||
else {
|
||||
char* p = (char*)GlobalLock(hClipboardData);
|
||||
SIZE_T size = GlobalSize(hClipboardData);
|
||||
std::string str;
|
||||
str.assign(p, size);
|
||||
result->Success(flutter::EncodableValue(str));
|
||||
GlobalUnlock(hClipboardData);
|
||||
}
|
||||
CloseClipboard();
|
||||
}
|
||||
else {
|
||||
result->NotImplemented();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void PasteboardPluginRegisterWithRegistrar(
|
||||
FlutterDesktopPluginRegistrarRef registrar) {
|
||||
PasteboardPlugin::RegisterWithRegistrar(
|
||||
flutter::PluginRegistrarManager::GetInstance()
|
||||
->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
/*
|
||||
------------------------------------------------------------------------------
|
||||
This software is available under 2 licenses -- choose whichever you prefer.
|
||||
------------------------------------------------------------------------------
|
||||
ALTERNATIVE A - MIT License
|
||||
Copyright (c) 2019-2021 JavaCommons
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
------------------------------------------------------------------------------
|
||||
ALTERNATIVE B - Public Domain (www.unlicense.org)
|
||||
This is free and unencumbered software released into the public domain.
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
|
||||
software, either in source code form or as a compiled binary, for any purpose,
|
||||
commercial or non-commercial, and by any means.
|
||||
In jurisdictions that recognize copyright laws, the author or authors of this
|
||||
software dedicate any and all copyright interest in the software to the public
|
||||
domain. We make this dedication for the benefit of the public at large and to
|
||||
the detriment of our heirs and successors. We intend this dedication to be an
|
||||
overt act of relinquishment in perpetuity of all present and future rights to
|
||||
this software under copyright law.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
||||
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
-----------------------------------------------------------------------------
|
||||
*/
|
||||
/* strconv.h v1.8.10 */
|
||||
/* Last Modified: 2021/08/30 21:53 */
|
||||
#ifndef STRCONV_H
|
||||
#define STRCONV_H
|
||||
|
||||
#include <windows.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
|
||||
#if __cplusplus >= 201103L && !defined(STRCONV_CPP98)
|
||||
static inline std::wstring cp_to_wide(const std::string &s, UINT codepage)
|
||||
{
|
||||
int in_length = (int)s.length();
|
||||
int out_length = MultiByteToWideChar(codepage, 0, s.c_str(), in_length, 0, 0);
|
||||
std::wstring result(out_length, L'\0');
|
||||
if (out_length)
|
||||
MultiByteToWideChar(codepage, 0, s.c_str(), in_length, &result[0], out_length);
|
||||
return result;
|
||||
}
|
||||
static inline std::string wide_to_cp(const std::wstring &s, UINT codepage)
|
||||
{
|
||||
int in_length = (int)s.length();
|
||||
int out_length = WideCharToMultiByte(codepage, 0, s.c_str(), in_length, 0, 0, 0, 0);
|
||||
std::string result(out_length, '\0');
|
||||
if (out_length)
|
||||
WideCharToMultiByte(codepage, 0, s.c_str(), in_length, &result[0], out_length, 0, 0);
|
||||
return result;
|
||||
}
|
||||
#else /* __cplusplus < 201103L */
|
||||
static inline std::wstring cp_to_wide(const std::string &s, UINT codepage)
|
||||
{
|
||||
int in_length = (int)s.length();
|
||||
int out_length = MultiByteToWideChar(codepage, 0, s.c_str(), in_length, 0, 0);
|
||||
std::vector<wchar_t> buffer(out_length);
|
||||
if (out_length)
|
||||
MultiByteToWideChar(codepage, 0, s.c_str(), in_length, &buffer[0], out_length);
|
||||
std::wstring result(buffer.begin(), buffer.end());
|
||||
return result;
|
||||
}
|
||||
static inline std::string wide_to_cp(const std::wstring &s, UINT codepage)
|
||||
{
|
||||
int in_length = (int)s.length();
|
||||
int out_length = WideCharToMultiByte(codepage, 0, s.c_str(), in_length, 0, 0, 0, 0);
|
||||
std::vector<char> buffer(out_length);
|
||||
if (out_length)
|
||||
WideCharToMultiByte(codepage, 0, s.c_str(), in_length, &buffer[0], out_length, 0, 0);
|
||||
std::string result(buffer.begin(), buffer.end());
|
||||
return result;
|
||||
}
|
||||
#endif
|
||||
|
||||
static inline std::string cp_to_utf8(const std::string &s, UINT codepage)
|
||||
{
|
||||
if (codepage == CP_UTF8)
|
||||
return s;
|
||||
std::wstring wide = cp_to_wide(s, codepage);
|
||||
return wide_to_cp(wide, CP_UTF8);
|
||||
}
|
||||
static inline std::string utf8_to_cp(const std::string &s, UINT codepage)
|
||||
{
|
||||
if (codepage == CP_UTF8)
|
||||
return s;
|
||||
std::wstring wide = cp_to_wide(s, CP_UTF8);
|
||||
return wide_to_cp(wide, codepage);
|
||||
}
|
||||
|
||||
static inline std::wstring ansi_to_wide(const std::string &s)
|
||||
{
|
||||
return cp_to_wide(s, CP_ACP);
|
||||
}
|
||||
static inline std::string wide_to_ansi(const std::wstring &s)
|
||||
{
|
||||
return wide_to_cp(s, CP_ACP);
|
||||
}
|
||||
|
||||
static inline std::wstring sjis_to_wide(const std::string &s)
|
||||
{
|
||||
return cp_to_wide(s, 932);
|
||||
}
|
||||
static inline std::string wide_to_sjis(const std::wstring &s)
|
||||
{
|
||||
return wide_to_cp(s, 932);
|
||||
}
|
||||
|
||||
static inline std::wstring utf8_to_wide(const std::string &s)
|
||||
{
|
||||
return cp_to_wide(s, CP_UTF8);
|
||||
}
|
||||
static inline std::string wide_to_utf8(const std::wstring &s)
|
||||
{
|
||||
return wide_to_cp(s, CP_UTF8);
|
||||
}
|
||||
|
||||
static inline std::string ansi_to_utf8(const std::string &s)
|
||||
{
|
||||
return cp_to_utf8(s, CP_ACP);
|
||||
}
|
||||
static inline std::string utf8_to_ansi(const std::string &s)
|
||||
{
|
||||
return utf8_to_cp(s, CP_ACP);
|
||||
}
|
||||
|
||||
static inline std::string sjis_to_utf8(const std::string &s)
|
||||
{
|
||||
return cp_to_utf8(s, 932);
|
||||
}
|
||||
static inline std::string utf8_to_sjis(const std::string &s)
|
||||
{
|
||||
return utf8_to_cp(s, 932);
|
||||
}
|
||||
|
||||
#ifdef __cpp_char8_t
|
||||
static inline std::u8string utf8_to_char8(const std::string &s)
|
||||
{
|
||||
return std::u8string(s.begin(), s.end());
|
||||
}
|
||||
static inline std::string char8_to_utf8(const std::u8string &s)
|
||||
{
|
||||
return std::string(s.begin(), s.end());
|
||||
}
|
||||
|
||||
static inline std::wstring char8_to_wide(const std::u8string &s)
|
||||
{
|
||||
return cp_to_wide(char8_to_utf8(s), CP_UTF8);
|
||||
}
|
||||
static inline std::u8string wide_to_char8(const std::wstring &s)
|
||||
{
|
||||
return utf8_to_char8(wide_to_cp(s, CP_UTF8));
|
||||
}
|
||||
|
||||
static inline std::u8string cp_to_char8(const std::string &s, UINT codepage)
|
||||
{
|
||||
return utf8_to_char8(cp_to_utf8(s, codepage));
|
||||
}
|
||||
static inline std::string char8_to_cp(const std::u8string &s, UINT codepage)
|
||||
{
|
||||
return utf8_to_cp(char8_to_utf8(s), codepage);
|
||||
}
|
||||
|
||||
static inline std::u8string ansi_to_char8(const std::string &s)
|
||||
{
|
||||
return cp_to_char8(s, CP_ACP);
|
||||
}
|
||||
static inline std::string char8_to_ansi(const std::u8string &s)
|
||||
{
|
||||
return char8_to_cp(s, CP_ACP);
|
||||
}
|
||||
|
||||
static inline std::u8string sjis_to_char8(const std::string &s)
|
||||
{
|
||||
return cp_to_char8(s, 932);
|
||||
}
|
||||
static inline std::string char8_to_sjis(const std::u8string &s)
|
||||
{
|
||||
return char8_to_cp(s, 932);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(_MSC_VER)
|
||||
#pragma warning(push)
|
||||
#pragma warning(disable : 4996)
|
||||
#endif
|
||||
|
||||
static inline std::wstring vformat(const wchar_t *format, va_list args)
|
||||
{
|
||||
int len = _vsnwprintf(0, 0, format, args);
|
||||
if (len < 0)
|
||||
return L"";
|
||||
std::vector<wchar_t> buffer(len + 1);
|
||||
len = _vsnwprintf(&buffer[0], len, format, args);
|
||||
if (len < 0)
|
||||
return L"";
|
||||
buffer[len] = L'\0';
|
||||
return &buffer[0];
|
||||
}
|
||||
static inline std::string vformat(const char *format, va_list args)
|
||||
{
|
||||
int len = _vsnprintf(0, 0, format, args);
|
||||
if (len < 0)
|
||||
return "";
|
||||
std::vector<char> buffer(len + 1);
|
||||
len = _vsnprintf(&buffer[0], len, format, args);
|
||||
if (len < 0)
|
||||
return "";
|
||||
buffer[len] = '\0';
|
||||
return &buffer[0];
|
||||
}
|
||||
#ifdef __cpp_char8_t
|
||||
static inline std::u8string vformat(const char8_t *format, va_list args)
|
||||
{
|
||||
int len = _vsnprintf(0, 0, (const char *)format, args);
|
||||
if (len < 0)
|
||||
return u8"";
|
||||
std::vector<char> buffer(len + 1);
|
||||
len = _vsnprintf(&buffer[0], len, (const char *)format, args);
|
||||
if (len < 0)
|
||||
return u8"";
|
||||
buffer[len] = '\0';
|
||||
return (char8_t *)&buffer[0];
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(_MSC_VER)
|
||||
#pragma warning(pop)
|
||||
#endif
|
||||
|
||||
static inline std::wstring format(const wchar_t *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::wstring s = vformat(format, args);
|
||||
va_end(args);
|
||||
return s;
|
||||
}
|
||||
static inline std::string format(const char *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::string s = vformat(format, args);
|
||||
va_end(args);
|
||||
return s;
|
||||
}
|
||||
#ifdef __cpp_char8_t
|
||||
static inline std::u8string format(const char8_t *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::u8string s = vformat(format, args);
|
||||
va_end(args);
|
||||
return s;
|
||||
}
|
||||
#endif
|
||||
|
||||
static inline void format(std::ostream &ostrm, const wchar_t *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::wstring s = vformat(format, args);
|
||||
va_end(args);
|
||||
ostrm << wide_to_utf8(s) << std::flush;
|
||||
}
|
||||
static inline void format(std::ostream &ostrm, const char *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::string s = vformat(format, args);
|
||||
va_end(args);
|
||||
ostrm << s << std::flush;
|
||||
}
|
||||
#ifdef __cpp_char8_t
|
||||
static inline void format(std::ostream &ostrm, const char8_t *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::u8string s = vformat(format, args);
|
||||
va_end(args);
|
||||
ostrm << char8_to_utf8(s) << std::flush;
|
||||
}
|
||||
#endif
|
||||
|
||||
static inline std::string formatA(const wchar_t *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::wstring s = vformat(format, args);
|
||||
va_end(args);
|
||||
return wide_to_ansi(s);
|
||||
}
|
||||
static inline std::string formatA(const char *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::string s = vformat(format, args);
|
||||
va_end(args);
|
||||
return utf8_to_ansi(s);
|
||||
}
|
||||
#ifdef __cpp_char8_t
|
||||
static inline std::string formatA(const char8_t *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::u8string s = vformat(format, args);
|
||||
va_end(args);
|
||||
return char8_to_ansi(s);
|
||||
}
|
||||
#endif
|
||||
|
||||
static inline void formatA(std::ostream &ostrm, const wchar_t *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::wstring s = vformat(format, args);
|
||||
va_end(args);
|
||||
ostrm << wide_to_ansi(s) << std::flush;
|
||||
}
|
||||
static inline void formatA(std::ostream &ostrm, const char *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::string s = vformat(format, args);
|
||||
va_end(args);
|
||||
ostrm << utf8_to_ansi(s) << std::flush;
|
||||
}
|
||||
#ifdef __cpp_char8_t
|
||||
static inline void formatA(std::ostream &ostrm, const char8_t *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::u8string s = vformat(format, args);
|
||||
va_end(args);
|
||||
ostrm << char8_to_ansi(s) << std::flush;
|
||||
}
|
||||
#endif
|
||||
|
||||
static inline void dbgmsg(const wchar_t *title, const wchar_t *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::wstring s = vformat(format, args);
|
||||
va_end(args);
|
||||
MessageBoxW(0, s.c_str(), title, MB_OK);
|
||||
}
|
||||
static inline void dbgmsg(const char *title, const char *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::string s = vformat(format, args);
|
||||
va_end(args);
|
||||
MessageBoxW(0, utf8_to_wide(s).c_str(), utf8_to_wide(title).c_str(), MB_OK);
|
||||
}
|
||||
#ifdef __cpp_char8_t
|
||||
static inline void dbgmsg(const char8_t *title, const char8_t *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::u8string s = vformat(format, args);
|
||||
va_end(args);
|
||||
MessageBoxW(0, char8_to_wide(s).c_str(), char8_to_wide(title).c_str(), MB_OK);
|
||||
}
|
||||
#endif
|
||||
|
||||
static inline HANDLE handle_for_ostream(std::ostream &ostrm)
|
||||
{
|
||||
if (&ostrm == &std::cout)
|
||||
{
|
||||
return GetStdHandle(STD_OUTPUT_HANDLE);
|
||||
}
|
||||
else if (&ostrm == &std::cerr)
|
||||
{
|
||||
return GetStdHandle(STD_ERROR_HANDLE);
|
||||
}
|
||||
return INVALID_HANDLE_VALUE;
|
||||
}
|
||||
static inline void dbgout(std::ostream &ostrm, const wchar_t *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::wstring ws = vformat(format, args);
|
||||
va_end(args);
|
||||
HANDLE h = handle_for_ostream(ostrm);
|
||||
if (h == INVALID_HANDLE_VALUE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
DWORD dwNumberOfCharsWrite;
|
||||
if (GetFileType(h) != FILE_TYPE_CHAR)
|
||||
{
|
||||
std::string s = wide_to_cp(ws, GetConsoleOutputCP());
|
||||
WriteFile(h, s.c_str(), (DWORD)s.size(), &dwNumberOfCharsWrite, NULL);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteConsoleW(h,
|
||||
ws.c_str(),
|
||||
(DWORD)ws.size(),
|
||||
&dwNumberOfCharsWrite,
|
||||
NULL);
|
||||
}
|
||||
}
|
||||
static inline void dbgout(std::ostream &ostrm, const char *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::string s = vformat(format, args);
|
||||
va_end(args);
|
||||
HANDLE h = handle_for_ostream(ostrm);
|
||||
if (h == INVALID_HANDLE_VALUE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
DWORD dwNumberOfCharsWrite;
|
||||
if (GetFileType(h) != FILE_TYPE_CHAR)
|
||||
{
|
||||
s = utf8_to_cp(s, GetConsoleOutputCP());
|
||||
WriteFile(h, s.c_str(), (DWORD)s.size(), &dwNumberOfCharsWrite, NULL);
|
||||
}
|
||||
else
|
||||
{
|
||||
std::wstring ws = utf8_to_wide(s);
|
||||
WriteConsoleW(h,
|
||||
ws.c_str(),
|
||||
(DWORD)ws.size(),
|
||||
&dwNumberOfCharsWrite,
|
||||
NULL);
|
||||
}
|
||||
}
|
||||
#ifdef __cpp_char8_t
|
||||
static inline void dbgout(std::ostream &ostrm, const char8_t *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
std::u8string s = vformat(format, args);
|
||||
va_end(args);
|
||||
HANDLE h = handle_for_ostream(ostrm);
|
||||
if (h == INVALID_HANDLE_VALUE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
DWORD dwNumberOfCharsWrite;
|
||||
if (GetFileType(h) != FILE_TYPE_CHAR)
|
||||
{
|
||||
std::string str = char8_to_cp(s, GetConsoleOutputCP());
|
||||
WriteFile(h, (const char *)str.c_str(), (DWORD)str.size(), &dwNumberOfCharsWrite, NULL);
|
||||
}
|
||||
else
|
||||
{
|
||||
std::wstring ws = char8_to_wide(s);
|
||||
WriteConsoleW(h,
|
||||
ws.c_str(),
|
||||
(DWORD)ws.size(),
|
||||
&dwNumberOfCharsWrite,
|
||||
NULL);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
class unicode_ostream
|
||||
{
|
||||
private:
|
||||
std::ostream *m_ostrm;
|
||||
UINT m_target_cp;
|
||||
bool is_ascii(const std::string &s)
|
||||
{
|
||||
for (std::size_t i = 0; i < s.size(); i++)
|
||||
{
|
||||
unsigned char c = (unsigned char)s[i];
|
||||
if (c > 0x7f)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public:
|
||||
unicode_ostream(std::ostream &ostrm, UINT target_cp = CP_ACP) : m_ostrm(&ostrm), m_target_cp(target_cp) {}
|
||||
std::ostream &stream() { return *m_ostrm; }
|
||||
void stream(std::ostream &ostrm) { m_ostrm = &ostrm; }
|
||||
UINT target_cp() { return m_target_cp; }
|
||||
void target_cp(UINT cp) { m_target_cp = cp; }
|
||||
template <typename T>
|
||||
unicode_ostream &operator<<(const T &x)
|
||||
{
|
||||
std::ostringstream oss;
|
||||
oss << x;
|
||||
std::string output = oss.str();
|
||||
if (is_ascii(output))
|
||||
{
|
||||
(*m_ostrm) << x;
|
||||
}
|
||||
else
|
||||
{
|
||||
(*m_ostrm) << utf8_to_cp(output, m_target_cp);
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
unicode_ostream &operator<<(const std::wstring &x)
|
||||
{
|
||||
(*m_ostrm) << wide_to_cp(x, m_target_cp);
|
||||
return *this;
|
||||
}
|
||||
unicode_ostream &operator<<(const wchar_t *x)
|
||||
{
|
||||
(*m_ostrm) << wide_to_cp(x, m_target_cp);
|
||||
return *this;
|
||||
}
|
||||
unicode_ostream &operator<<(const std::string &x)
|
||||
{
|
||||
(*m_ostrm) << utf8_to_cp(x, m_target_cp);
|
||||
return *this;
|
||||
}
|
||||
unicode_ostream &operator<<(const char *x)
|
||||
{
|
||||
(*m_ostrm) << utf8_to_cp(x, m_target_cp);
|
||||
return *this;
|
||||
}
|
||||
#ifdef __cpp_char8_t
|
||||
unicode_ostream &operator<<(const std::u8string &x)
|
||||
{
|
||||
(*m_ostrm) << char8_to_cp(x, m_target_cp);
|
||||
return *this;
|
||||
}
|
||||
unicode_ostream &operator<<(const char8_t *x)
|
||||
{
|
||||
(*m_ostrm) << char8_to_cp(x, m_target_cp);
|
||||
return *this;
|
||||
}
|
||||
#endif
|
||||
unicode_ostream &operator<<(std::ostream &(*pf)(std::ostream &)) // For manipulators...
|
||||
{
|
||||
(*m_ostrm) << pf;
|
||||
return *this;
|
||||
}
|
||||
unicode_ostream &operator<<(std::basic_ios<char> &(*pf)(std::basic_ios<char> &)) // For manipulators...
|
||||
{
|
||||
(*m_ostrm) << pf;
|
||||
return *this;
|
||||
}
|
||||
};
|
||||
|
||||
#define U8(X) ((const char *)u8##X)
|
||||
#define WIDE(X) (L##X)
|
||||
|
||||
#endif /* STRCONV_H */
|
||||
Reference in new issue
Block a user