diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt index 6eba5c9b..42a3e232 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt @@ -10,13 +10,17 @@ import com.ethran.notable.editor.EditorViewModel import com.ethran.notable.editor.PageView import com.ethran.notable.editor.drawing.selectPaint import com.ethran.notable.editor.state.Mode +import com.ethran.notable.editor.utils.DeviceCompat import com.ethran.notable.editor.utils.pointsToPath +import com.ethran.notable.editor.utils.enableNativeEraser import com.ethran.notable.editor.utils.refreshScreenRegion import com.ethran.notable.editor.utils.resetScreenFreeze import com.ethran.notable.utils.logCallStack +import com.onyx.android.sdk.api.device.epd.EpdController import com.onyx.android.sdk.pen.TouchHelper import io.shipbook.shipbooksdk.Log import io.shipbook.shipbooksdk.ShipBook +import kotlinx.coroutines.launch class CanvasRefreshManager( private val drawCanvas: DrawCanvas, @@ -66,7 +70,72 @@ class CanvasRefreshManager( resetScreenFreeze(touchHelper) } - fun drawCanvasToView(dirtyRect: Rect?) { + /** + * Atomic erase commit for both the pen-button eraser and scribble-to-erase. Pushes the + * already-repainted page bitmap to the panel while the screen is still frozen, then fully + * toggles setRawDrawingEnabled(false→true) to drop the firmware layer atomically — eraser + * indicator and erased strokes disappear in one transition with no gap to draw into. + * ORDER IS CRITICAL: push first, then drop. See docs/onyx-sdk/onyx-pen-up-refresh-and-screen-freeze.md. + * Must be called after the page bitmap has already been repainted (after handleErase / + * handleScribbleToErase). + */ + fun commitErase(dirtyRect: Rect?, areaErase: Boolean = false) { + val dirty = dirtyRect ?: Rect(0, 0, page.viewWidth, page.viewHeight) + // 1. Block input immediately so no stroke can start during the swap/settle. + touchHelper?.setRawInputReaderEnable(false) + drawCanvas.coroutineScope.launch { + // 2. Push the erased page bitmap to the EPD *while still frozen* (drawBitmapToSurfaceSync + // forces it through with enablePost(0)+enablePost(1), like kreader's renderToScreen). + drawBitmapToSurfaceSync(dirty) + // 3. Now drop the firmware raw layer (eraser indicator / scribble ink). The panel + // already shows the clean page, so this is a no-flash transition. + touchHelper?.setRawDrawingEnabled(false) + // 4. Settle before re-arming (150ms stroke / 500ms area), mirroring the official app. + DeviceCompat.delayBeforeResumingDrawing(isErasing = true, areaErase = areaErase) + // 5. Re-arm raw drawing. The heavy toggle resets the eraser channel and stroke + // style, so re-assert both (matches the official C(true) path). + if (viewModel.toolbarState.value.isDrawing) { + touchHelper?.setRawDrawingEnabled(true) + enableNativeEraser(touchHelper) + drawCanvas.inputHandler.updatePenAndStroke() + touchHelper?.setRawInputReaderEnable(true) + } else { + log.w("commitErase: not in drawing mode, leaving raw drawing disabled") + } + } + } + + /** Synchronous variant of [drawCanvasToView] (no `post{}` hop). Locks, draws the page + * bitmap for [dirtyRect], and posts — on the calling thread. */ + private fun drawBitmapToSurfaceSync(dirtyRect: Rect?) { + val zoneToRedraw = dirtyRect ?: Rect(0, 0, page.viewWidth, page.viewHeight) + var canvas: Canvas? = null + try { + canvas = drawCanvas.holder.lockCanvas(zoneToRedraw) + if (canvas == null) { + log.e("commitErase: failed to lock canvas (surface invalid/destroyed)") + return + } + // enablePost(0)+enablePost(1) forces the bitmap through to the EPD even while the + // screen is frozen (a bare unlockCanvasAndPost is swallowed). Mirrors kreader's + // RxBaseReaderRequest.unlockCanvas. + EpdController.enablePost(0) + EpdController.enablePost(1) + canvas.drawBitmap(page.windowedBitmap, zoneToRedraw, zoneToRedraw, Paint()) + } catch (e: IllegalStateException) { + log.w("Surface released during erase draw", e) + } finally { + try { + if (canvas != null) drawCanvas.holder.unlockCanvasAndPost(canvas) + // Let the EPD apply the pushed region (kreader's afterUnlockCanvas equivalent). + EpdController.resetViewUpdateMode(drawCanvas) + } catch (e: IllegalStateException) { + log.w("Surface released during unlock", e) + } + } + } + + fun drawCanvasToView(dirtyRect: Rect?, onPosted: (() -> Unit)? = null) { drawCanvas.post { val zoneToRedraw = dirtyRect ?: Rect(0, 0, page.viewWidth, page.viewHeight) var canvas: Canvas? = null @@ -104,6 +173,8 @@ class CanvasRefreshManager( log.v("Canvas refreshed") } catch (e: IllegalStateException) { log.w("Surface released during unlock", e) + } finally { + onPosted?.invoke() } } } diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt b/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt index b26b89c7..b93c297d 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt @@ -53,7 +53,14 @@ class DrawCanvas( parent?.requestDisallowInterceptTouchEvent(true) - if (!DeviceCompat.isOnyxDevice || inputHandler.isErasing) { + // NATIVE ERASER INDICATOR: + // On Onyx devices the eraser stroke is now rendered natively by the firmware + // (see einkHelper.setupSurface -> setEraserRawDrawingEnabled), so we no longer + // route erase touches into the OpenGL front-buffer renderer. Non-Onyx devices + // still use OpenGL as their only renderer. The original condition is kept + // (commented) as a reference. See docs/onyx-sdk/onyx-native-eraser-indicator.md. + // if (!DeviceCompat.isOnyxDevice || inputHandler.isErasing) { + if (!DeviceCompat.isOnyxDevice) { glRenderer.onTouchListener.onTouch(this, event) } diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt index ba362764..b30e368a 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt @@ -6,7 +6,6 @@ import android.graphics.RectF import android.util.Log import androidx.compose.ui.unit.dp import androidx.core.graphics.toRect -import com.ethran.notable.data.datastore.GlobalAppSettings import com.ethran.notable.editor.EditorViewModel import com.ethran.notable.editor.state.Mode import com.ethran.notable.editor.PageView @@ -17,16 +16,14 @@ import com.ethran.notable.editor.utils.Pen import com.ethran.notable.editor.utils.calculateBoundingBox import com.ethran.notable.editor.utils.copyInput import com.ethran.notable.editor.utils.copyInputToSimplePointF +import com.ethran.notable.editor.utils.enableNativeEraser import com.ethran.notable.editor.utils.getModifiedStrokeEndpoints import com.ethran.notable.editor.utils.handleDraw import com.ethran.notable.editor.utils.handleErase import com.ethran.notable.editor.utils.handleScribbleToErase import com.ethran.notable.editor.utils.handleSelect import com.ethran.notable.editor.utils.onSurfaceInit -import com.ethran.notable.editor.utils.partialRefreshRegionOnce import com.ethran.notable.editor.utils.penToStroke -import com.ethran.notable.editor.utils.prepareForPartialUpdate -import com.ethran.notable.editor.utils.restoreDefaults import com.ethran.notable.editor.utils.setupSurface import com.ethran.notable.editor.utils.transformToLine import com.ethran.notable.ui.convertDpToPixel @@ -95,26 +92,21 @@ class OnyxInputHandler( // Handle button/eraser tip of the pen: override fun onBeginRawErasing(p0: Boolean, p1: TouchPoint?) { if (touchHelper == null) return - if (GlobalAppSettings.current.openGLRendering) { - prepareForPartialUpdate(drawCanvas, touchHelper!!) - log.d("Eraser Mode") - } + // Re-assert the native eraser indicator because setRawDrawingEnabled(true) (called + // on every resume) resets it to disabled internally. See docs/onyx-sdk/onyx-native-eraser-indicator.md. + enableNativeEraser(touchHelper) + applyEraserIndicatorStyle() isErasing = true } override fun onEndRawErasing(p0: Boolean, p1: TouchPoint?) { - if (GlobalAppSettings.current.openGLRendering) { - restoreDefaults(drawCanvas) - drawCanvas.glRenderer.clearPointBuffer() - } - drawCanvas.glRenderer.frontBufferRenderer?.cancel() + updatePenAndStroke() } override fun onRawErasingTouchPointListReceived(plist: TouchPointList?) = onRawErasingList(plist) override fun onRawErasingTouchPointMoveReceived(p0: TouchPoint?) { -// if (p0 == null) return } override fun onPenUpRefresh(refreshRect: RectF?) { @@ -136,37 +128,49 @@ class OnyxInputHandler( ?.setStrokeWidth(toolbarState.penSettings[toolbarState.pen.penName]!!.strokeSize * page.zoomLevel.value) ?.setStrokeColor(toolbarState.penSettings[toolbarState.pen.penName]!!.color) - Mode.Erase -> { - when (toolbarState.eraser) { - Eraser.PEN -> touchHelper!!.setStrokeStyle(penToStroke(Pen.MARKER)) - ?.setStrokeWidth(30f) - ?.setStrokeColor(Color.GRAY) - - Eraser.SELECT -> { - val dashStyleID = penToStroke(Pen.DASHED) - touchHelper!!.setStrokeStyle(dashStyleID) - ?.setStrokeWidth(3f) - ?.setStrokeColor(Color.BLACK) - val params = FloatArray(4) - params[0] = 5f // thickness - params[1] = 9f // no idea - params[2] = 9f // no idea - params[3] = 0f // no idea - Device.currentDevice().setStrokeParameters(dashStyleID, params) - } - } - } + Mode.Erase -> applyEraserIndicatorStyle(penEraserColor = Color.GRAY) Mode.Select -> touchHelper?.setStrokeStyle(penToStroke(Pen.BALLPEN))?.setStrokeWidth(3f) ?.setStrokeColor(Color.GRAY) } } + /** + * Configures the helper's stroke so the eraser feedback matches the active eraser type: + * a marker for the pen eraser, and a dashed line for the lasso / select eraser. Shared + * by the hand eraser (Mode.Erase in [updatePenAndStroke]) and the pen side-button + * eraser ([onBeginRawErasing], native indicator). + * + * @param penEraserColor colour for the [Eraser.PEN] marker. Hand-erase uses grey; the + * native button-erase indicator uses black (matches the user's preference and is more + * visible against ink). + */ + private fun applyEraserIndicatorStyle(penEraserColor: Int = Color.BLACK) { + if (touchHelper == null) return + when (toolbarState.eraser) { + Eraser.PEN -> touchHelper!!.setStrokeStyle(penToStroke(Pen.MARKER)) + ?.setStrokeWidth(30f) + ?.setStrokeColor(penEraserColor) + + Eraser.SELECT -> { + val dashStyleID = penToStroke(Pen.DASHED) + touchHelper!!.setStrokeStyle(dashStyleID) + ?.setStrokeWidth(3f) + ?.setStrokeColor(Color.BLACK) + val params = FloatArray(4) + params[0] = 5f // thickness + params[1] = 9f // no idea + params[2] = 9f // no idea + params[3] = 0f // no idea + Device.currentDevice().setStrokeParameters(dashStyleID, params) + } + } + } + suspend fun updateIsDrawing() { if(touchHelper == null) return log.i("Update is drawing: $toolbarState.isDrawing") if (toolbarState.isDrawing) { -// DeviceCompat.delayBeforeResumingDrawing() touchHelper!!.setRawDrawingEnabled(true) } else { // Check if drawing is completed @@ -197,12 +201,6 @@ class OnyxInputHandler( val currentLastStrokeEndTime = lastStrokeEndTime lastStrokeEndTime = System.currentTimeMillis() val startTime = System.currentTimeMillis() - // sometimes UI will get refreshed and frozen before we draw all the strokes. - // I think, its because of doing it in separate thread. Commented it for now, to - // observe app behavior, and determine if it fixed this bug, - // as I do not know reliable way to reproduce it - // Need testing if it will be better to do in main thread on, in separate. - // thread(start = true, isDaemon = false, priority = Thread.MAX_PRIORITY) { when (toolbarState.mode) { Mode.Erase -> onRawErasingList(plist) @@ -228,12 +226,6 @@ class OnyxInputHandler( } } - // After each stroke ends, we draw it on our canvas. - // This way, when screen unfreezes the strokes are shown. - // When in scribble mode, ui want be refreshed. - // If we UI will be refreshed and frozen before we manage to draw - // strokes want be visible, so we need to ensure that it will be done - // before anything else happens. Mode.Line -> { coroutineScope.launch(Dispatchers.Main.immediate) { CanvasEventBus.drawingInProgress.withLock { @@ -264,7 +256,6 @@ class OnyxInputHandler( max(startPoint.x, endPoint.x).toInt(), max(startPoint.y, endPoint.y).toInt() ) -// partialRefreshRegionOnce(this@DrawCanvas, dirtyRect) drawCanvas.refreshManager.refreshUi(dirtyRect) CanvasEventBus.commitHistorySignal.emit(Unit) } @@ -279,8 +270,6 @@ class OnyxInputHandler( val lock = System.currentTimeMillis() log.d("lock obtained in ${lock - startTime} ms") - // Thread.sleep(1000) - // transform points to page space val scaledPoints = copyInput(plist.points, page.scroll, page.zoomLevel.value) val firstPointTime = plist.points.first().timestamp @@ -305,13 +294,23 @@ class OnyxInputHandler( ) } else { log.d("Erased by scribble, $erasedByScribbleDirtyRect") - drawCanvas.refreshManager.drawCanvasToView(erasedByScribbleDirtyRect) - partialRefreshRegionOnce( - drawCanvas, - erasedByScribbleDirtyRect, - touchHelper!! + // Union the scribble track (firmware screen coords) with the erased + // strokes' bounds so commitErase overwrites both in one pass while + // still frozen. Scribble is not drawn into the page bitmap — we only + // need the region to cover the firmware's live track. + // See docs/onyx-sdk/onyx-scribble-to-erase.md. + val padding = 10 + val trackBox = + calculateBoundingBox(plist.points) { Pair(it.x, it.y) }.toRect() + val dirty = Rect( + trackBox.left - padding, + trackBox.top - padding, + trackBox.right + padding, + trackBox.bottom + padding ) - + erasedByScribbleDirtyRect.let { dirty.union(it) } + // Use areaErase=true for the longer 500ms settle (scribble is a large gesture). + drawCanvas.refreshManager.commitErase(dirty, areaErase = true) } } @@ -327,8 +326,6 @@ class OnyxInputHandler( isErasing = false if (plist == null) return - plist.points - val points = copyInputToSimplePointF(plist.points, page.scroll, page.zoomLevel.value) val padding = 10 @@ -339,16 +336,24 @@ class OnyxInputHandler( boundingBox.right + padding, boundingBox.bottom + padding ) - drawCanvas.refreshManager.refreshUi(strokeArea) - val zoneEffected = handleErase( drawCanvas.page, history, points, eraser = toolbarState.eraser ) - if (zoneEffected != null) - drawCanvas.refreshManager.refreshUi(zoneEffected) + + // Single atomic commit of the whole touched region: the native eraser indicator + // track spans strokeArea, the erased strokes' bounds are zoneEffected, so repainting + // their union both wipes the indicator and shows the erased result in one pass. + // commitErase blocks input, draws synchronously, then drops the firmware overlay so + // indicator + strokes disappear together (no double refresh, no gap to draw into). + // See docs/onyx-sdk/onyx-pen-up-refresh-and-screen-freeze.md. + val dirty = Rect(strokeArea) + if (zoneEffected != null) dirty.union(zoneEffected) + // Area (lasso/select) erase needs the longer 500ms settle the official app uses; the + // pen/marker erase uses the 150ms stroke settle. + drawCanvas.refreshManager.commitErase(dirty, areaErase = toolbarState.eraser == Eraser.SELECT) } } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt b/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt index 7a36f731..24a4b4f4 100644 --- a/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt +++ b/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt @@ -3,10 +3,9 @@ package com.ethran.notable.editor.drawing import android.graphics.Canvas import android.graphics.Paint import com.onyx.android.sdk.data.note.TouchPoint -import com.onyx.android.sdk.pen.NeoFountainPenV2 -import com.onyx.android.sdk.pen.NeoPenConfig -import com.onyx.android.sdk.pen.PenPathResult -import com.onyx.android.sdk.pen.PenResult +import com.onyx.android.sdk.pen.NeoFountainPenWrapper +import com.onyx.android.sdk.pen.NeoPenRender +import com.onyx.android.sdk.pen.utils.FountainShapes import io.shipbook.shipbooksdk.ShipBook private val logger = ShipBook.getLogger("NeoFountainPenV2Wrapper") @@ -21,73 +20,55 @@ object NeoFountainPenV2Wrapper { strokeWidth: Float, maxTouchPressure: Float, ) { - if (points.size < 2) { logger.e("Drawing strokes failed: Not enough points") return } - // Normalize pressure to [0, 1] using provided maxTouchPressure - if (maxTouchPressure > 0f) { - for (i in points.indices) { - points[i].pressure /= maxTouchPressure - } - } + // Fountain V2 produces closed (filled) paths. The pressure must be in [0, 1]. + // Work on a copy so we never mutate the caller's points (otherwise re-rendering + // the same stroke after an unfreeze would normalize the pressures again). + val renderPoints = copyAndNormalizePressure(points, maxTouchPressure) - val neoPenConfig = NeoPenConfig().apply { - setWidth(strokeWidth) - setTiltEnabled(true) - setMaxTouchPressure(maxTouchPressure) - } - val neoPen = NeoFountainPenV2.create(neoPenConfig) + // Mirror the official demo (BrushScribbleShape) via FountainShapes.createNeoPenV2 so + // config matches the firmware's live rendering. fastMode=false yields PenPathResult + // (smooth vector path); true would give discrete dab stamps and look faceted on redraw. + // See docs/onyx-sdk/onyx-neo-fountain-pen-v2.md. + val neoPen = FountainShapes.createNeoPenV2( + strokeWidth, // width + NeoFountainPenWrapper.MIN_FOUNTAIN_PEN_WIDTH, // minWidth + 1.0f, // displayScaleX + 1.0f, // displayScaleY + 1.0f, // scalePrecision + 1.0f, // createScale + null, // pressureSensitivity -> default 0.3 + false, // fastMode -> false = smooth PenPathResult + null, // smoothLevel -> default 0.6 + ) if (neoPen == null) { logger.e("Drawing strokes failed: Pen creation failed") return } + val penRender = NeoPenRender(neoPen) try { - // Pen down - drawResult( - neoPen.onPenDown(points.first(), repaint = true), - canvas, - paint - ) - - // Moves (exclude first and last) - if (points.size > 2) { - drawResult( - neoPen.onPenMove( - points.subList(1, points.size - 1), - prediction = null, - repaint = true - ), - canvas, - paint - ) - } - - // Pen up - drawResult( - neoPen.onPenUp(points.last(), repaint = true), - canvas, - paint - ) + // render() runs the full onTouchDown/onTouchMove/onTouchDone pipeline including + // the trailing prediction segment; this is required for complete stroke coverage. + penRender.render(canvas, paint, renderPoints) } finally { - neoPen.destroy() + penRender.destroyPen() } } - - private fun drawResult( - result: Pair?, - canvas: Canvas, - paint: Paint - ) { - val first = result?.first - if (first !is PenPathResult) { - logger.d("Expected PenPathResult but got ${first?.javaClass?.simpleName ?: "null"}") - return + private fun copyAndNormalizePressure( + points: List, + maxTouchPressure: Float, + ): List { + val needNormalize = maxTouchPressure > 0f && points.any { it.pressure > 1.0f } + return points.map { p -> + TouchPoint(p).apply { + if (needNormalize) pressure /= maxTouchPressure + } } - first.draw(canvas, paint = paint) } } diff --git a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt index 3e51ec59..28468118 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt @@ -1,6 +1,5 @@ package com.ethran.notable.editor.utils -import android.content.Context import android.graphics.Rect import android.os.Build import android.view.View @@ -34,100 +33,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds private val log = ShipBook.getLogger("einkHelper") -/** - * ONYX EPD REFRESH MODES GUIDE (Generated by AI, better then nothing) - * ============================================ - * - * ### 1. Update Modes (Quality ↔ Speed Tradeoff) - * These control the balance between refresh speed and display quality: - * - * | Mode | Description | - * |--------------|---------------------------------------------------| - * | `GC` | Full refresh (Best quality, slowest, no ghosting) | - * | `GU` | Grayscale update (Partial refresh, preserves tone)| - * | `DU` | Direct update (Faster, partial, moderate quality) | - * | `ANIMATION` | Smooth animation updates (some ghosting possible) | - * | `A2` | Fastest update (Lowest quality, minimal latency) | - * | `DEEP_GC` | Deep clean refresh (Best for text rendering) | - * - * ### 2. Display Schemes (System-Wide Presets) - * Affect the global system behavior: - * - * | Scheme | Description | - * |-------------------------------|--------------------------------------| - * | `SCHEME_NORMAL` | Default balanced e-ink mode | - * | `SCHEME_KEYBOARD` | Optimized for keyboard input | - * | `SCHEME_SCRIBBLE` | Best for handwriting/stylus input | - * | `SCHEME_APPLICATION_ANIMATION` | App-specific animation optimization | - * | `SCHEME_SYSTEM_ANIMATION` | System-wide animation optimization | - * - * ### 3. Special Functions (System-Level Tools) - * - * | Function | Description | - * |---------------------------|--------------------------------------------------| - * | `applyGCOnce()` | Force one full-screen GC refresh | - * | `repaintEveryThing()` | Repaints the entire screen with current mode | - * | `fillWhiteOnWakeup()` | Clears residual ghosting on wake | - * | `useGCForNewSurface()` | Forces GC when new surfaces appear | - * | `setEpdTurbo(int)` | Adjusts refresh speed (0 = slow, 100 = fastest) | - * - * ### 4. View-Specific Controls - * Fine-grained control per view: - * - * | Function | Description | - * |----------------------------------|--------------------------------------------| - * | `setViewDefaultUpdateMode()` | Sets default update mode for a specific view | - * | `resetViewUpdateMode()` | Resets view update mode to system default | - * | `disableA2ForSpecificView()` | Disables A2 mode for that view | - * | `handwritingRepaint()` | Optimized refresh for handwriting regions | - * - * ### 5. Waveform Controls (Advanced Tuning) - * - * | Function | Description | - * |-------------------|----------------------------------------| - * | `enableRegal()` | Enables Regal waveform for less ghosting | - * | `disableRegal()` | Disables Regal waveform | - * | `setTrigger()` | Sets waveform trigger count | - * | `byPass()` | Skips scheduled waveform updates | - * - * ### 6. Visual Enhancements - * Adjust display contrast and rendering: - * - * | Function | Description | - * |----------------------------------|----------------------------------------------| - * | `applyGammaCorrection()` | Adjusts gamma curve for contrast | - * | `applyMonoLevel()` | Controls monochrome intensity | - * | `applyColorFilter()` | Adjusts color tones (for color e-ink only) | - * | `setWebViewContrastOptimize()` | Enhances contrast in WebViews | - * - * ### Common Usage Patterns: - * - * // For animations or video playback: - * EpdController.setDisplayScheme(SCHEME_APPLICATION_ANIMATION) - * EpdController.setViewDefaultUpdateMode(view, ANIMATION) - * - * // For standard reading mode: - * EpdController.setDisplayScheme(SCHEME_NORMAL) - * EpdController.applyGCOnce() // To remove ghosting - * - * // For handwriting or drawing apps: - * EpdController.setDisplayScheme(SCHEME_SCRIBBLE) - * EpdController.handwritingRepaint(inkView, dirtyRect) - * - * // To reset view to default behavior: - * EpdController.resetViewUpdateMode(view) - * EpdController.setDisplayScheme(SCHEME_NORMAL) - */ - - -/** - * Toggles animation-optimized mode for smoother UI interactions. - * - * @param isAnimationMode true to enable fast/animation mode, false for normal refresh - */ fun setAnimationMode(isAnimationMode: Boolean) { // reference: // https://github.com/onyx-intl/OnyxAndroidDemo/blob/d3a1ffd3af231fe4de60a2a0da692c17cb35ce31/app/OnyxPenDemo/src/main/java/com/onyx/android/eink/pen/demo/ui/PenDemoActivity.java#L500 @@ -271,18 +180,34 @@ fun setupSurface(view: View, touchHelper: TouchHelper?, toolbarHeight: Int) { .openRawDrawing() touchHelper.setRawDrawingEnabled(true) + + // Enable the firmware's native eraser indicator. MUST be called after setRawDrawingEnabled(true) + // because that call internally resets it to disabled. Also re-asserted in onBeginRawErasing. + // See docs/onyx-sdk/onyx-native-eraser-indicator.md. + enableNativeEraser(touchHelper) log.i("Setup editable surface completed") } +/** + * Enables the firmware's native eraser-stroke rendering for pen side-button erasing. + * MUST be called after every setRawDrawingEnabled(true) (which resets it to disabled). + * Wrapped in try/catch because the Onyx SDK is unstable across devices/firmware. + */ +fun enableNativeEraser(touchHelper: TouchHelper?) { + if (touchHelper == null) return + try { + touchHelper.setEraserRawDrawingEnabled(true, TouchHelper.STROKE_STYLE_MARKER) + } catch (t: Throwable) { + log.w("setEraserRawDrawingEnabled not supported on this device: ${t.message}") + } +} + fun prepareForPartialUpdate(view: View, touchHelper: TouchHelper?) { if(touchHelper == null) return EpdController.setDisplayScheme(SCHEME_SCRIBBLE) -// EpdController.useFastScheme() // the same as above EpdController.enableA2ForSpecificView(view) -// EpdController.enablePost(view, 1) EpdController.setEpdTurbo(100) - //exit of drawing mode touchHelper.isRawDrawingRenderEnabled = false touchHelper.isRawDrawingRenderEnabled = true } @@ -300,38 +225,24 @@ fun refreshScreenRegion(view: View, dirtyRect: Rect) { dirtyRect.height(), UpdateMode.ANIMATION_MONO ) -// EpdController.handwritingRepaint(view, dirtyRect) } -//https://github.com/onyx-intl/OnyxAndroidDemo/blob/d3a1ffd3af231fe4de60a2a0da692c17cb35ce31/doc/EPD-Screen-Update.md fun refreshScreen() { - // TODO: It does nothing, I have no idea why. EpdController.repaintEveryThing(UpdateMode.REGAL_PLUS) -// EpdController.refreshScreen(view, UpdateMode.ANIMATION_MONO) } - - fun restoreDefaults(view: View) { -// EpdController.resetViewUpdateMode(view) EpdController.setDisplayScheme(SCHEME_NORMAL) - } - fun partialRefreshRegionOnce(view: View, dirtyRect: Rect, touchHelper: TouchHelper?) { if(touchHelper == null) return -// touchHelper.isRawDrawingRenderEnabled = false refreshScreenRegion(view, dirtyRect) resetScreenFreeze(touchHelper) - // we need to wait before refreshing, as onyx library has its own buffer that needs to be updated. Otherwise we will refresh to correct, then incorrect and then correct state. -// delay(100) -// resetScreenFreeze(touchHelper) } fun resetScreenFreeze(touchHelper: TouchHelper?, view: View? = null) { - if(touchHelper == null) - { + if(touchHelper == null) { log.w("touchHelper is null") return } @@ -340,18 +251,8 @@ fun resetScreenFreeze(touchHelper: TouchHelper?, view: View? = null) { DeviceCompat.delayBeforeResumingDrawing() touchHelper.isRawDrawingRenderEnabled = true } -// setRawDrawingEnabled(false) -// setRawDrawingEnabled(true) } -// Device.currentDevice().invalidate(this@DrawCanvas, UpdateMode.ANIMATION_MONO); -// EpdController.refreshScreen(view, UpdateMode.ANIMATION_MONO) -// EpdController.setEpdTurbo(100) -// EpdController.clearTransientUpdate(true) -// EpdController.repaintEveryThing() -// EpdController.setScreenHandWritingPenState(view, EpdPenManager.PEN_PAUSE) -// EpdController.setScreenHandWritingPenState(view, EpdPenManager.PEN_DRAWING) - /** * Automatically toggles e‑ink animation mode when the attached subtree scrolls. @@ -456,12 +357,20 @@ object DeviceCompat { false } } - suspend fun delayBeforeResumingDrawing() { + suspend fun delayBeforeResumingDrawing(isErasing: Boolean = false, areaErase: Boolean = false) { if (!isOnyxDevice) return - // 500ms for Kaleido Color e-ink, 300ms for monochrome - val delayMs = if (isColorDevice()) 500L else 300L - log.d("delayBeforeResumingDrawing: Delaying raw drawing resume for ${delayMs}ms to allow Android UI to settle") - delay(delayMs) + // Delays mirror kreader's WaitForUpdateFinishedAction: + // - erase: 150ms stroke, 500ms area (lasso). Safe at 150ms because commitErase uses the + // heavy setRawDrawingEnabled toggle which hands the screen back atomically. + // - normal pen: 500ms color, 300ms monochrome. + // See docs/onyx-sdk/onyx-pen-up-refresh-and-screen-freeze.md. + val delay = when { + isErasing -> if (areaErase) 500.milliseconds else 150.milliseconds + isColorDevice() -> 500.milliseconds + else -> 300.milliseconds + } + log.d("delayBeforeResumingDrawing(isErasing=$isErasing): Delaying raw drawing resume for ${delay}ms") + delay(delay) log.d("delayBeforeResumingDrawing: Resuming raw drawing") } } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/editor/utils/eraser.kt b/app/src/main/java/com/ethran/notable/editor/utils/eraser.kt index 6b02b341..24484717 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/eraser.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/eraser.kt @@ -131,9 +131,14 @@ fun handleScribbleToErase( val deletedStrokeIds = deletedStrokes.map { it.id } page.removeStrokes(deletedStrokeIds) history.addOperationsToHistory(listOf(Operation.AddStroke(deletedStrokes))) - val dirtyRect = strokeBounds(deletedStrokes) - page.drawAreaPageCoordinates(dirtyRect) - return dirtyRect + // Return the erased region in SCREEN coordinates (mirrors handleErase). The caller + // pushes this rect to the SurfaceView/EPD via commitErase, and the surface bitmap + // (windowedBitmap) is in screen space — returning page coords here pushed the wrong + // region whenever scrolled/zoomed. The caller unions this with the scribble track so + // the firmware ink is cleared in the same post. See docs/onyx-sdk/onyx-scribble-to-erase.md. + val effectedArea = page.toScreenCoordinates(strokeBounds(deletedStrokes)) + page.drawAreaScreenCoordinates(screenArea = effectedArea) + return effectedArea } return null } diff --git a/app/src/main/java/com/ethran/notable/editor/utils/handleInput.kt b/app/src/main/java/com/ethran/notable/editor/utils/handleInput.kt index 4be84445..ead9c804 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/handleInput.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/handleInput.kt @@ -6,11 +6,25 @@ import com.ethran.notable.data.model.SimplePointF import com.onyx.android.sdk.data.note.TouchPoint +// Max delta time (ms) that fits in the uint16 dt channel. 0xFFFF (65535) is reserved as a +// null sentinel by the SB1 stroke encoder, so the largest storable delta is 65534 ms +// (~65 s). Strokes longer than that clamp their tail to this value (acceptable: such +// durations only occur for pathological strokes). Keep in sync with +// StrokePointConverter.DT_MAX_VALUE_INT. +private const val DT_MAX_VALUE_MS = 65534L + fun copyInput(touchPoints: List, scroll: Offset, scale: Float): List { - val points = touchPoints.map { - it.toStrokePoint(scroll, scale) + if (touchPoints.isEmpty()) return emptyList() + // Capture per-point delta time (ms relative to the first point) into StrokePoint.dt so + // it can be persisted. The firmware stamps each TouchPoint with an absolute timestamp; + // we store only the delta, which is far cheaper (uint16 vs a per-point absolute long) + // and is all that velocity-dependent renderers need. See StrokePoint.dt and the SB1 + // DT channel in StrokePointConverter. + val baseTime = touchPoints.first().timestamp + return touchPoints.map { + val deltaMs = (it.timestamp - baseTime).coerceIn(0L, DT_MAX_VALUE_MS) + it.toStrokePoint(scroll, scale).copy(dt = deltaMs.toUShort()) } - return points }