packages feed

nano-ui-0.1.0.0: lib/NanoUI/Bidi.hs

-- | Direction runs of a line of mixed left-to-right and right-to-left text,
-- for hosts that shape one direction at a time.
--
-- This is the implicit part of the Unicode bidirectional algorithm (UAX #9)
-- for a single line: the paragraph direction from the first strong
-- character, European and Arabic numbers, neutrals between runs, and
-- reordering by level. Explicit embeddings, overrides, isolates and bracket
-- pairs are not handled; text using them lays out as if they were absent.
module NanoUI.Bidi
  ( BidiRun (..)
  , bidiRuns
  , needsBidi
  ) where

import Control.Applicative ((<|>))
import Data.Char (ord)
import Data.List.NonEmpty qualified as NE
import Data.Text (Text)
import Data.Text qualified as T

-- | Characters @runStart@ up to @runEnd@ (exclusive) shaped in one direction.
data BidiRun = BidiRun
  { runStart :: !Int
  , runEnd :: !Int
  , runRightToLeft :: !Bool
  }
  deriving (Eq, Show)

data Class = L | R | AL | EN | AN | NSM | WS | ON
  deriving (Eq, Show)

-- | Whether a line has characters that can flow right to left. A line
-- without them is one left-to-right run.
needsBidi :: Text -> Bool
needsBidi = T.any (\c -> ord c >= 0x0590 && rtlOrArabic (classify c))
  where
    rtlOrArabic k = k == R || k == AL || k == AN

-- | The runs of a line in visual order, left to right.
bidiRuns :: Text -> [BidiRun]
bidiRuns txt
  | T.null txt = []
  | not (needsBidi txt) = [BidiRun 0 (T.length txt) False]
  | otherwise =
      let classes0 = map classify (T.unpack txt)
          paragraphRtl = case [k | k <- classes0, k == L || k == R || k == AL] of
            k : _ -> k /= L
            [] -> False
          base = if paragraphRtl then 1 else 0 :: Int
          classes = resolveNeutrals paragraphRtl (resolveWeak paragraphRtl classes0)
          levels = map (implicitLevel base) classes
          indexed = zip [0 :: Int ..] levels
          runs =
            [ (start, fst (NE.last grp) + 1, lvl)
            | grp <- NE.groupBy (\a b -> snd a == snd b) indexed
            , let (start, lvl) = NE.head grp
            ]
       in [BidiRun s e (odd lvl) | (s, e, lvl) <- reorder runs]

classify :: Char -> Class
classify c
  | n < 0x0590 = latin
  | n <= 0x05FF = if n >= 0x0591 && n <= 0x05C7 && n /= 0x05BE && n /= 0x05C0 && n /= 0x05C3 && n /= 0x05C6 then NSM else R
  | n >= 0x0660 && n <= 0x0669 = AN
  | n >= 0x06F0 && n <= 0x06F9 = EN
  | n >= 0x064B && n <= 0x065F || n == 0x0670 || n >= 0x06D6 && n <= 0x06ED = NSM
  | n <= 0x06FF = AL
  | n <= 0x07BF = if n >= 0x0730 && n <= 0x074A || n >= 0x07A6 && n <= 0x07B0 then NSM else AL
  | n <= 0x085F = R
  | n <= 0x08FF = if n >= 0x08D3 then NSM else AL
  | n >= 0x200E && n <= 0x200F = if n == 0x200E then L else R
  | n >= 0xFB1D && n <= 0xFB4F = R
  | n >= 0xFB50 && n <= 0xFDFF = AL
  | n >= 0xFE70 && n <= 0xFEFF = AL
  | n >= 0x2000 && n <= 0x206F = if n <= 0x200A || n == 0x2028 || n == 0x2029 then WS else ON
  | n >= 0x10800 && n <= 0x10FFF = R
  | n >= 0x1E800 && n <= 0x1EFFF = AL
  | otherwise = L
  where
    n = ord c
    latin
      | c >= '0' && c <= '9' = EN
      | c == ' ' || c == '\t' = WS
      | n < 0x80 && not (isAsciiLetter c) = ON
      | n >= 0x80 && n <= 0xBF = ON
      | n == 0xD7 || n == 0xF7 = ON
      | n >= 0x0300 && n <= 0x036F = NSM
      | otherwise = L
    isAsciiLetter ch = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')

-- | Weak types: a mark takes the class before it, a European number after
-- Arabic letters reads as an Arabic number and one after Latin letters (or
-- at the start of a left-to-right line) as Latin.
resolveWeak :: Bool -> [Class] -> [Class]
resolveWeak paragraphRtl = go ON (if paragraphRtl then R else L)
  where
    go _ _ [] = []
    go prev lastStrong (k : ks) =
      let k1 = if k == NSM then prev else k
          k2
            | k1 == EN && lastStrong == AL = AN
            | k1 == EN && lastStrong == L = L
            | otherwise = k1
          k3 = if k2 == AL then R else k2
          lastStrong' = if k1 == L || k1 == R || k1 == AL then k1 else lastStrong
       in k3 : go k1 lastStrong' ks

-- | Neutrals between two characters of the same direction take it (numbers
-- count as right to left here); other neutrals take the paragraph's.
resolveNeutrals :: Bool -> [Class] -> [Class]
resolveNeutrals paragraphRtl classes =
  let direction k
        | k == L = Just False
        | k == R || k == AN || k == EN = Just True
        | otherwise = Nothing
      directions = map direction classes
      before = scanl (\acc d -> d <|> acc) Nothing directions
      after = drop 1 (scanr (<|>) Nothing directions)
      resolve k b a
        | k == WS || k == ON = case (b, a) of
            (Just x, Just y) | x == y -> if x then R else L
            _ -> if paragraphRtl then R else L
        | otherwise = k
   in zipWith3 resolve classes before after

-- | The embedding level of a resolved class at paragraph level @base@.
implicitLevel :: Int -> Class -> Int
implicitLevel base k
  | even base = case k of
      R -> base + 1
      AL -> base + 1
      AN -> base + 2
      EN -> base + 2
      _ -> base
  | otherwise = case k of
      L -> base + 1
      EN -> base + 1
      AN -> base + 1
      _ -> base

-- | Reverse every maximal sequence of runs at or above each odd level, from
-- the highest level down.
reorder :: [(Int, Int, Int)] -> [(Int, Int, Int)]
reorder runs =
  let maxLevel = maximum (0 : [l | (_, _, l) <- runs])
      lowestOdd = minimum (maxLevel + 1 : [l | (_, _, l) <- runs, odd l])
      pass lvl rs =
        concatMap
          (\grp -> if atLeast (NE.head grp) then reverse (NE.toList grp) else NE.toList grp)
          (NE.groupBy (\a b -> atLeast a == atLeast b) rs)
        where
          atLeast (_, _, l) = l >= lvl
   in foldl (flip pass) runs [maxLevel, maxLevel - 1 .. lowestOdd]