JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
15.92k stars 1.16k forks source link

Desktop. Support Right-To-Left languages #872

Open igordmn opened 3 years ago

igordmn commented 3 years ago

Compose 0.5.0-build235

Core Compose already supports right-to-left languages and changes layout accordingly.

All we need is to get LayoutDirection at the window/component level and pass it down to all internal classes (DesktopOwner, SkijaLayer, Window.setIcon, Tray).

We can use this property:

package androidx.compose.ui.util

import androidx.compose.ui.unit.LayoutDirection
import java.awt.ComponentOrientation
import java.util.Locale

internal val Locale.layoutDirection: LayoutDirection
    get() {
        return if (ComponentOrientation.getOrientation(this).isLeftToRight) {
            LayoutDirection.Ltr
        } else {
            LayoutDirection.Rtl
        }
    }

get it from the component in ComposeLayer.setContent: component.locale.layoutDirection and pass it to DesktopOwner

Besides layout, we need to be sure that any RTL text looks okay.

kirill-grouchnikov commented 2 years ago

Hopefully this is work in progress and being planned for release 1.0.0. Since there hasn't been any update on this tracker, I'd love to hear your thoughts on the following proposal.

From the application perspective, I'd love to see a Compose-friendly way to wrap Locale.getDefault() and Locale.setDefault() to enable the following:

  1. Access to both read and update the current application-level locale from anywhere in the hierarchy of any window. This would allow creating a menu item, a toolbar button, or any other affordance for the user to switch to a different language in the app.
  2. Automatic recomposition of all live content in all active windows for strings that were obtained from a ResourceBundle created with that wrapped / observable Locale

This is something that I've been playing in Aurora in the last week or so, at different levels in the stack. The first is all application-level. Somewhere in your application block you would have:

    val currLocale = mutableStateOf(Locale.getDefault())
    val resourceBundle = derivedStateOf {
        ResourceBundle
            .getBundle("org.pushingpixels.aurora.demo.Resources", currLocale.value)
    }

The first one is a mutable wrapper around Locale.getDefault(), and the second one is a derived ResourceBundle. The next step is to wrap each Window content block to set the layout direction:

CompositionLocalProvider(
            LocalLayoutDirection provides
                    if (ComponentOrientation.getOrientation(currLocale.value).isLeftToRight)
                        LayoutDirection.Ltr else LayoutDirection.Rtl,
        ) {
          // "real" content
        }

Finally, you pass your ResourceBundle into every composable that needs to display strings for the UI (or maybe you create a new derivedStateOf instead of passing it around), and you pass locale: MutableState<Locale> into every composable that needs to either read or write the locale value. Then, updating the app-level locale can look like this:

locale.value = Locale("en", "US")
Locale.setDefault(locale.value)
window.applyComponentOrientation(ComponentOrientation.getOrientation(locale.value))

The first line updates the observable mutable wrapper, the second updates the Swing-level locale, and the third optional one updates the embedded Swing content in this window, or all the other ones.

This is a bit cumbersome since it would require copy-pasting these blocks everywhere you need access to read and / or write the current Locale value.

Going to post this comment and then do the second part.

kirill-grouchnikov commented 2 years ago

The second part is a proposal to fold this common logic into Application.desktop.kt.

First, add var applicationLocale: Locale to the ApplicationScope interface.

In awaitApplication the implementation of that interface becomes

            val currLocale = mutableStateOf(Locale.getDefault())
            val applicationScope = object : ApplicationScope {
                override var applicationLocale: Locale
                    get() = currLocale.value
                    set(value) {
                        Locale.setDefault(value)
                        currLocale.value = value
                    }

                override fun exitApplication() {
                    isOpen = false
                }
            }

See how the setter also updates Locale.setDefault so that it can be read by embedded Swing components if needed

Finally, in the composition.setContent block, add the same definition of LocalLayoutDirection in addition to LocalDensity:

LocalLayoutDirection provides
   if (ComponentOrientation.getOrientation(currLocale.value).isLeftToRight)
      LayoutDirection.Ltr else LayoutDirection.Rtl
kirill-grouchnikov commented 2 years ago

With this second proposal, applicationLocale becomes available to read and write anywhere for ApplicationScope blocks, and pass it around to other blocks with different scopes if necessary. It highlights that Locale is not per-window, but rather a global language choice for the entire application. And it also exposes it as a mutable state, so that ResourceBundle objects can be derived from it, with the entire window composable hierarchy getting updated for every single window when the current locale is updated

kirill-grouchnikov commented 2 years ago

A side note - I can almost get there in Aurora, except for a couple of things.

GlobalSnapshotManager.ensureStarted() is internal and not accessible outside of the androidx.compose.ui.platform package. GlobalDensity is in the similar bucket, but at least its implementation can be copied and expanded as:

                                LocalDensity provides
                                        Density(
                                            GraphicsEnvironment.getLocalGraphicsEnvironment()
                                                .defaultScreenDevice
                                                .defaultConfiguration.defaultTransform.scaleX.toFloat(),
                                            fontScale = 1f
                                        )

And androidx.compose.ui.configureSwingGlobalsForCompose is marked as experimental - but it's public, which is nice.

igordmn commented 2 years ago

Thanks for the detailed proposal!

Changing/reading the current locale would be a nice feature.

There are multiple things we need to consider:

  1. currentLocale looks similar to LocalLayoutDirection/LocalDensity. The question, why we don't make something like LocalLocale too, which will be available in any Composable, and can be overridden for child Composable's.
  2. at first glance it doesn't seem right to change a global Locale via application's variable. application is designed in a way, that it shouldn't touch the outer scope (if that is possible). Also, Locale.setDefault is java-only thing, and isn't needed for Compose, we can just pass Locale down, to the Composable tree.
  3. maybe we can make LocalLocale a commonMain thing.

A side note - I can almost get there in Aurora, except for a couple of things.

Are you trying to implement application with extra features? Maybe wrapping it will help?

fun auroraApplication(content: @Composable AuroraApplicationScope.() -> Unit) {
  application { 
    AuroraApplicationScope(this).content()
  }
}

class AuroraApplicationScope(private original: ApplicationScope) : ApplicationScope by ApplicationScope {
  ...
}

P.S. If reading RTL probably will be in 1.0, designing/implementing currentLocale/LocalLocale most certainly will be only in post-1.0 versions

kirill-grouchnikov commented 2 years ago

Thanks Igor. I have a first pass for this in Aurora at https://github.com/kirill-grouchnikov/aurora/commit/12adb644a27ee0531454d1110141418ad2909e54

First, there's the "extended" application scope:

interface AuroraLocaleScope {
    var applicationLocale: Locale
}

class AuroraApplicationScope(private val original: ApplicationScope, private val currLocale: MutableState<Locale>) :
    ApplicationScope, AuroraLocaleScope {
    override var applicationLocale: Locale
        get() = currLocale.value
        set(value) {
            Locale.setDefault(value)
            currLocale.value = value
        }

    override fun exitApplication() {
        original.exitApplication()
    }
}

fun auroraApplication(content: @Composable AuroraApplicationScope.() -> Unit) {
    application {
        val currLocale = mutableStateOf(Locale.getDefault())
        CompositionLocalProvider(
            LocalLayoutDirection provides
                    if (ComponentOrientation.getOrientation(currLocale.value).isLeftToRight)
                        LayoutDirection.Ltr else LayoutDirection.Rtl
        ) {
            AuroraApplicationScope(this, currLocale).content()
        }
    }
}

Then, if the app is using the auroraApplication instead of application, it can get access to applicationLocale to create a ResourceBundle as derivedStateOf. It can also pass ::applicationLocale property reference down to a composable that can do localeProperty.setter.call(Locale("iw", "IL")) to switch the locale, and have the entire UI recomposed with strings from the updated resource bundle.

kirill-grouchnikov commented 2 years ago

This is not the cleanest, I think. I'll go back to your points in the next day or so to continue this discussion.

AltNico commented 2 years ago

Thanks for the hints to providing LocalLayoutDirection in order to switch to the RTL direction in Compose. As a reply to @igordmn's

Besides layout, we need to be sure that any RTL text looks okay.

I may link to a little experiment I did at Briar Desktop. In general the direction switching works, however, a lot of things are broken, like padding and overlapping of elements that otherwise look fine in LTR mode. Also, most applications do the following with mixed Latin and Arabic texts: if a line starts with a Latin character, the text is aligned to the left and starts on the left. If a line starts with an Arabic character, it's the same just on the right.

Here's the full report on the experiment together with a lot of images:

https://code.briarproject.org/briar/briar-desktop/-/issues/293#note_63432

okushnikov commented 3 weeks ago

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.