Text.swift 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. /// A view the displays text.
  2. ///
  3. /// ``Text`` truncates its content to fit within its proposed size. To wrap
  4. /// without truncation, put the ``Text`` (or its enclosing view hierarchy) into
  5. /// an ideal height context such as a ``ScrollView``. Alternatively, use
  6. /// ``View/fixedSize(horizontal:vertical:)`` with `horizontal` set to false and
  7. /// `vertical` set to true, but be aware that this may lead to unintuitive
  8. /// minimum sizing behaviour when used within a window. Often when developers
  9. /// use ``View/fixedSize()`` on text, what they really need is a ``ScrollView``.
  10. ///
  11. /// To avoid wrapping and truncation entirely, use ``View/fixedSize()``.
  12. ///
  13. /// ## Technical notes
  14. ///
  15. /// The reason that ``Text`` truncates its content to fit its proposed size is
  16. /// that SwiftCrossUI's layout system behaves rather unintuitively with views
  17. /// that trade off width for height. The layout system used to support this
  18. /// behaviour well, but when overhauling the layout system with performance in
  19. /// mind, we discovered that it's not possible to handle minimum view sizing in
  20. /// the intuitive way that we were, without a large performance cost or layout
  21. /// system complexity cost.
  22. ///
  23. /// With the current system, windows determine the minimum size of their content
  24. /// by proposing a size of 0x0. A text view that doesn't truncate its content
  25. /// would take on a width of 0 and then lay out each character on a new line (as
  26. /// that's what most UI frameworks do when text is given a small width). This
  27. /// leads to the window thinking that its minimum height is
  28. /// `characterCount * lineHeight`, even though when given a width larger than
  29. /// zero, the text view would be shorter than this 'minimum height'. The
  30. /// underlying cause is the assumption that 'minimum size' is a sensible notion
  31. /// for every view. A text view without truncation doesn't have a
  32. /// 'minimum size'; are we minimizing width? height? width + height? area?
  33. ///
  34. /// SwiftCrossUI's old layout system separated the concept of minimum size into
  35. /// 'minimum width for current height', and 'minimum height for current width'.
  36. /// This led to much more intuitive window sizing behaviour. If you had
  37. /// non-truncating text inside a window, and resized the width of the window
  38. /// such that the height of the text became taller than the window, then the
  39. /// window would become taller, and if you resized the height of the window then
  40. /// you'd reach the window's minimum height before the text could overflow the
  41. /// window horizontally. Unfortunately this required a lot of book-keeping, and
  42. /// was deemed to be unfeasible to do without significantly hurting performance
  43. /// due to all the layout assumptions that we'd have to drop from our stack
  44. /// layout algorithm.
  45. ///
  46. /// The new layout system behaviour is in line with SwiftUI's layout behaviour.
  47. public struct Text: Sendable {
  48. /// The string to be shown in the text view.
  49. public private(set) var string: String
  50. /// Creates a new text view that displays a string.
  51. ///
  52. /// - Parameter string: The string to display.
  53. public init(_ string: String) {
  54. self.string = string
  55. }
  56. }
  57. extension Text: View {
  58. public var _asMenuItems: [MenuItem] {
  59. [.text(self)]
  60. }
  61. }
  62. extension Text: ElementaryView {
  63. public func asWidget<Backend: BaseAppBackend>(
  64. backend: Backend
  65. ) -> Backend.Widget {
  66. return backend.createTextView()
  67. }
  68. public func computeLayout<Backend: BaseAppBackend>(
  69. _ widget: Backend.Widget,
  70. proposedSize: ProposedViewSize,
  71. environment: EnvironmentValues,
  72. backend: Backend
  73. ) -> ViewLayoutResult {
  74. let transformedString = environment.applyingTextTransforms(to: string)
  75. // TODO: Avoid this. Move it to commit once we figure out a solution for Gtk.
  76. // Even in dry runs we must update the underlying text view widget
  77. // because GtkBackend currently relies on querying the widget for text
  78. // properties and such (via Pango).
  79. backend
  80. .updateTextView(widget, content: transformedString, environment: environment)
  81. // UI frameworks often handle the zero proposal specially. We want to
  82. // have standard text sizing behaviour so it's better for us to never
  83. // propose zero in either dimension and then fix up the resulting size
  84. // to match our expectations.
  85. //
  86. // Our desired behaviour is for a zero width proposal to result in at least
  87. // one line's worth of height (for a non-empty string). Furthermore, if
  88. // proposed more than one line's worth of height, then a zero width
  89. // proposal should result in height equivalent to however many lines are
  90. // required to put each character of the text on a new line (excluding
  91. // whitespace).
  92. //
  93. // A zero height proposal should result in the text using at least one
  94. // line of height (if non-empty).
  95. var size = backend.size(
  96. of: transformedString,
  97. whenDisplayedIn: widget,
  98. proposedWidth: proposedSize.width.flatMap {
  99. // For text, an infinite proposal is the same as an unspecified
  100. // proposal, and this works nicer with most backends than converting
  101. // .infinity to a large integer (which is the alternative).
  102. $0 == .infinity ? nil : $0
  103. }.map(LayoutSystem.roundSize).map { max(1, $0) },
  104. proposedHeight: proposedSize.height.flatMap {
  105. $0 == .infinity ? nil : $0
  106. }.map(LayoutSystem.roundSize).map { max(1, $0) },
  107. environment: environment
  108. )
  109. // If the proposed width was 0 and the resuling width was 1, then set the
  110. // resulting width to 0. See above for more detail.
  111. if proposedSize.width == 0 && size.x == 1 {
  112. size.x = 0
  113. }
  114. return ViewLayoutResult.leafView(size: ViewSize(size))
  115. }
  116. public func commit<Backend: BaseAppBackend>(
  117. _ widget: Backend.Widget,
  118. layout: ViewLayoutResult,
  119. environment: EnvironmentValues,
  120. backend: Backend
  121. ) {
  122. backend.setSize(of: widget, to: layout.size.vector)
  123. }
  124. }