From 557729180f95b715605ff4cffb89aefaa710e2b3 Mon Sep 17 00:00:00 2001 From: Janne Koschinski <janne@kuschku.de> Date: Sun, 8 Apr 2018 22:01:48 +0200 Subject: [PATCH] Cleaning up input code --- .../quasseldroid/ui/chat/ChatActivity.kt | 101 +-- .../ui/chat/ChatFragmentProvider.kt | 6 +- .../ui/chat/input/AutoCompleteHelper.kt | 219 +++++++ .../ui/chat/input/AutoCompletionState.kt | 10 + .../ui/chat/input/ChatlineFragment.kt | 177 +++++ .../quasseldroid/ui/chat/input/Editor.kt | 610 ------------------ .../ui/chat/input/EditorHelper.kt | 206 ++++++ .../{FormatHandler.kt => RichEditText.kt} | 243 +++---- .../quasseldroid/ui/chat/input/RichToolbar.kt | 108 ++++ .../util/helper/CharSequenceHelper.kt | 130 +++- .../util/helper/EditableHelper.kt | 34 + .../quasseldroid/util/helper/SpannedHelper.kt | 142 +--- .../quasseldroid/util/ui/DoubleClickHelper.kt | 24 + .../util/ui/EditTextSelectionChange.kt | 4 +- app/src/main/res/layout-land/layout_main.xml | 9 +- .../res/layout-sw600dp-land/layout_main.xml | 9 +- ...ayout_slider.xml => fragment_chatline.xml} | 0 app/src/main/res/layout/layout_editor.xml | 16 +- app/src/main/res/layout/layout_main.xml | 9 +- app/src/main/res/layout/widget_formatting.xml | 227 +++---- .../quasseldroid/viewmodel/EditorViewModel.kt | 125 ++++ .../viewmodel/QuasselViewModel.kt | 105 --- 22 files changed, 1317 insertions(+), 1197 deletions(-) create mode 100644 app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/AutoCompleteHelper.kt create mode 100644 app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/AutoCompletionState.kt create mode 100644 app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/ChatlineFragment.kt delete mode 100644 app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/Editor.kt create mode 100644 app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/EditorHelper.kt rename app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/{FormatHandler.kt => RichEditText.kt} (51%) create mode 100644 app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/RichToolbar.kt create mode 100644 app/src/main/java/de/kuschku/quasseldroid/util/helper/EditableHelper.kt create mode 100644 app/src/main/java/de/kuschku/quasseldroid/util/ui/DoubleClickHelper.kt rename app/src/main/res/layout/{layout_slider.xml => fragment_chatline.xml} (100%) create mode 100644 viewmodel/src/main/java/de/kuschku/quasseldroid/viewmodel/EditorViewModel.kt diff --git a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/ChatActivity.kt b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/ChatActivity.kt index ad38ab225..de0b6ca40 100644 --- a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/ChatActivity.kt +++ b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/ChatActivity.kt @@ -10,9 +10,6 @@ import android.os.Bundle import android.os.PersistableBundle import android.support.v4.widget.DrawerLayout import android.support.v7.app.ActionBarDrawerToggle -import android.support.v7.widget.DefaultItemAnimator -import android.support.v7.widget.LinearLayoutManager -import android.support.v7.widget.RecyclerView import android.support.v7.widget.Toolbar import android.text.Html import android.view.* @@ -25,7 +22,6 @@ import de.kuschku.libquassel.protocol.Buffer_Type import de.kuschku.libquassel.protocol.Message import de.kuschku.libquassel.protocol.Message_Type import de.kuschku.libquassel.protocol.message.HandshakeMessage -import de.kuschku.libquassel.quassel.syncables.interfaces.IAliasManager import de.kuschku.libquassel.session.ConnectionState import de.kuschku.libquassel.util.flag.and import de.kuschku.libquassel.util.flag.hasFlag @@ -36,8 +32,7 @@ import de.kuschku.quasseldroid.persistence.AccountDatabase import de.kuschku.quasseldroid.persistence.QuasselDatabase import de.kuschku.quasseldroid.settings.MessageSettings import de.kuschku.quasseldroid.settings.Settings -import de.kuschku.quasseldroid.ui.chat.input.Editor -import de.kuschku.quasseldroid.ui.chat.input.MessageHistoryAdapter +import de.kuschku.quasseldroid.ui.chat.input.ChatlineFragment import de.kuschku.quasseldroid.ui.clientsettings.app.AppSettingsActivity import de.kuschku.quasseldroid.ui.coresettings.CoreSettingsActivity import de.kuschku.quasseldroid.util.helper.editCommit @@ -47,7 +42,6 @@ import de.kuschku.quasseldroid.util.helper.toLiveData import de.kuschku.quasseldroid.util.irc.format.IrcFormatDeserializer import de.kuschku.quasseldroid.util.service.ServiceBoundActivity import de.kuschku.quasseldroid.util.ui.MaterialContentLoadingProgressBar -import de.kuschku.quasseldroid.viewmodel.data.AutoCompleteItem import de.kuschku.quasseldroid.viewmodel.data.BufferData import javax.inject.Inject @@ -64,14 +58,6 @@ class ChatActivity : ServiceBoundActivity(), SharedPreferences.OnSharedPreferenc @BindView(R.id.editor_panel) lateinit var editorPanel: SlidingUpPanelLayout - @BindView(R.id.history_panel) - lateinit var historyPanel: SlidingUpPanelLayout - - @BindView(R.id.msg_history) - lateinit var msgHistory: RecyclerView - - private lateinit var drawerToggle: ActionBarDrawerToggle - @Inject lateinit var database: QuasselDatabase @@ -84,25 +70,16 @@ class ChatActivity : ServiceBoundActivity(), SharedPreferences.OnSharedPreferenc @Inject lateinit var ircFormatDeserializer: IrcFormatDeserializer - private lateinit var editor: Editor + private lateinit var drawerToggle: ActionBarDrawerToggle - private val panelSlideListener: SlidingUpPanelLayout.PanelSlideListener = object : - SlidingUpPanelLayout.PanelSlideListener { - override fun onPanelSlide(panel: View?, slideOffset: Float) = Unit - - override fun onPanelStateChanged(panel: View?, - previousState: SlidingUpPanelLayout.PanelState?, - newState: SlidingUpPanelLayout.PanelState?) { - editor.setMultiLine(newState == SlidingUpPanelLayout.PanelState.COLLAPSED) - } - } + private var chatlineFragment: ChatlineFragment? = null override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) if (intent != null) { when { intent.type == "text/plain" -> { - editor.formatHandler.replace(intent.getStringExtra(Intent.EXTRA_TEXT)) + chatlineFragment?.editorHelper?.replaceText(intent.getStringExtra(Intent.EXTRA_TEXT)) } } } @@ -113,58 +90,7 @@ class ChatActivity : ServiceBoundActivity(), SharedPreferences.OnSharedPreferenc setContentView(R.layout.activity_main) ButterKnife.bind(this) - editor = Editor( - this, - viewModel, - findViewById(R.id.chatline), - findViewById(R.id.send), - findViewById(R.id.tab_complete), - listOf( - findViewById(R.id.autocomplete_list), - findViewById(R.id.autocomplete_list_expanded) - ), - findViewById(R.id.formatting_toolbar), - ircFormatDeserializer, - appearanceSettings, - autoCompleteSettings, - messageSettings - ) - - editor.setOnSendListener { lines -> - viewModel.session { sessionOptional -> - val session = sessionOptional.orNull() - viewModel.buffer { bufferId -> - session?.bufferSyncer?.bufferInfo(bufferId)?.also { bufferInfo -> - val output = mutableListOf<IAliasManager.Command>() - for ((stripped, formatted) in lines) { - viewModel.addRecentlySentMessage(stripped) - session.aliasManager?.processInput(bufferInfo, formatted, output) - } - for (command in output) { - session.rpcHandler?.sendInput(command.buffer, command.message) - } - } - } - } - } - - editor.setOnPanelStateListener { expanded -> - historyPanel.panelState = if (expanded) - SlidingUpPanelLayout.PanelState.EXPANDED - else - SlidingUpPanelLayout.PanelState.COLLAPSED - } - - msgHistory.itemAnimator = DefaultItemAnimator() - msgHistory.layoutManager = LinearLayoutManager(this) - val messageHistoryAdapter = MessageHistoryAdapter() - messageHistoryAdapter.setOnItemClickListener { text -> - editor.formatHandler.replace(text) - historyPanel.panelState = SlidingUpPanelLayout.PanelState.COLLAPSED - } - msgHistory.adapter = messageHistoryAdapter - viewModel.recentlySentMessages_liveData - .observe(this, Observer(messageHistoryAdapter::submitList)) + chatlineFragment = supportFragmentManager.findFragmentById(R.id.fragment_chatline) as? ChatlineFragment setSupportActionBar(toolbar) @@ -315,8 +241,10 @@ class ChatActivity : ServiceBoundActivity(), SharedPreferences.OnSharedPreferenc invalidateOptionsMenu() }) - editorPanel.addPanelSlideListener(panelSlideListener) editorPanel.panelState = SlidingUpPanelLayout.PanelState.COLLAPSED + chatlineFragment?.panelSlideListener?.let(editorPanel::addPanelSlideListener) + + onNewIntent(intent) } var bufferData: BufferData? = null @@ -344,21 +272,8 @@ class ChatActivity : ServiceBoundActivity(), SharedPreferences.OnSharedPreferenc recreate() } super.onStart() - editor.onStart() } - override fun onStop() { - editor.onStop() - super.onStop() - } - - data class AutoCompletionState( - val originalWord: String, - val range: IntRange, - val lastCompletion: AutoCompleteItem? = null, - val completion: AutoCompleteItem - ) - override fun onSaveInstanceState(outState: Bundle?) { super.onSaveInstanceState(outState) outState?.putInt("OPEN_BUFFER", viewModel.buffer.value ?: -1) diff --git a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/ChatFragmentProvider.kt b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/ChatFragmentProvider.kt index ec98c3eac..905b76960 100644 --- a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/ChatFragmentProvider.kt +++ b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/ChatFragmentProvider.kt @@ -3,6 +3,7 @@ package de.kuschku.quasseldroid.ui.chat import dagger.Module import dagger.android.ContributesAndroidInjector import de.kuschku.quasseldroid.ui.chat.buffers.BufferViewConfigFragment +import de.kuschku.quasseldroid.ui.chat.input.ChatlineFragment import de.kuschku.quasseldroid.ui.chat.messages.MessageListFragment import de.kuschku.quasseldroid.ui.chat.nicks.NickListFragment @@ -19,4 +20,7 @@ abstract class ChatFragmentProvider { @ContributesAndroidInjector abstract fun bindToolbarFragment(): ToolbarFragment -} \ No newline at end of file + + @ContributesAndroidInjector + abstract fun bindChatlineFragment(): ChatlineFragment +} diff --git a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/AutoCompleteHelper.kt b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/AutoCompleteHelper.kt new file mode 100644 index 000000000..c0046653e --- /dev/null +++ b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/AutoCompleteHelper.kt @@ -0,0 +1,219 @@ +package de.kuschku.quasseldroid.ui.chat.input + +import android.arch.lifecycle.Observer +import android.graphics.Typeface +import android.support.v4.app.FragmentActivity +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import de.kuschku.libquassel.protocol.Buffer_Type +import de.kuschku.libquassel.quassel.syncables.IrcChannel +import de.kuschku.libquassel.util.IrcUserUtils +import de.kuschku.libquassel.util.flag.hasFlag +import de.kuschku.libquassel.util.helpers.value +import de.kuschku.quasseldroid.R +import de.kuschku.quasseldroid.settings.AutoCompleteSettings +import de.kuschku.quasseldroid.settings.MessageSettings +import de.kuschku.quasseldroid.util.helper.styledAttributes +import de.kuschku.quasseldroid.util.helper.toLiveData +import de.kuschku.quasseldroid.util.irc.format.IrcFormatDeserializer +import de.kuschku.quasseldroid.util.ui.TextDrawable +import de.kuschku.quasseldroid.viewmodel.EditorViewModel +import de.kuschku.quasseldroid.viewmodel.data.AutoCompleteItem +import de.kuschku.quasseldroid.viewmodel.data.BufferStatus + +class AutoCompleteHelper( + private val activity: FragmentActivity, + private val autoCompleteSettings: AutoCompleteSettings, + private val messageSettings: MessageSettings, + private val ircFormatDeserializer: IrcFormatDeserializer, + private val viewModel: EditorViewModel +) { + private var autocompleteListener: ((AutoCompletionState) -> Unit)? = null + private var dataListener: ((List<AutoCompleteItem>) -> Unit)? = null + + var autoCompletionState: AutoCompletionState? = null + + private val senderColors = activity.theme.styledAttributes( + R.attr.senderColor0, R.attr.senderColor1, R.attr.senderColor2, R.attr.senderColor3, + R.attr.senderColor4, R.attr.senderColor5, R.attr.senderColor6, R.attr.senderColor7, + R.attr.senderColor8, R.attr.senderColor9, R.attr.senderColorA, R.attr.senderColorB, + R.attr.senderColorC, R.attr.senderColorD, R.attr.senderColorE, R.attr.senderColorF + ) { + IntArray(length()) { getColor(it, 0) } + } + + init { + viewModel.autoCompleteData.toLiveData().observe(activity, Observer { + val query = it?.first ?: "" + val shouldShowResults = (autoCompleteSettings.auto && query.length >= 3) || + (autoCompleteSettings.prefix && query.startsWith('@')) || + (autoCompleteSettings.prefix && query.startsWith('#')) + val list = if (shouldShowResults) it?.second.orEmpty() else emptyList() + dataListener?.invoke(list.map { + if (it is AutoCompleteItem.UserItem) { + val nickName = it.nick + val senderColorIndex = IrcUserUtils.senderColor(nickName) + val rawInitial = nickName.trimStart(*IGNORED_CHARS).firstOrNull() + ?: nickName.firstOrNull() + val initial = rawInitial?.toUpperCase().toString() + val senderColor = senderColors[senderColorIndex] + + fun formatNick(nick: CharSequence): CharSequence { + val spannableString = SpannableString(nick) + spannableString.setSpan( + ForegroundColorSpan(senderColor), + 0, + nick.length, + SpannableString.SPAN_INCLUSIVE_EXCLUSIVE + ) + spannableString.setSpan( + StyleSpan(Typeface.BOLD), + 0, + nick.length, + SpannableString.SPAN_INCLUSIVE_EXCLUSIVE + ) + return spannableString + } + + it.copy( + displayNick = formatNick(it.nick), + fallbackDrawable = TextDrawable.builder().buildRound(initial, senderColor), + modes = when (messageSettings.showPrefix) { + MessageSettings.ShowPrefixMode.ALL -> + it.modes + else -> + it.modes.substring(0, Math.min(it.modes.length, 1)) + }, + realname = ircFormatDeserializer.formatString( + activity, it.realname.toString(), messageSettings.colorizeMirc + ) + ) + } else { + it + } + }) + }) + } + + fun setAutocompleteListener(listener: ((AutoCompletionState) -> Unit)?) { + this.autocompleteListener = listener + } + + fun setDataListener(listener: ((List<AutoCompleteItem>) -> Unit)?) { + this.dataListener = listener + } + + private fun autoCompleteDataFull(): List<AutoCompleteItem> { + return viewModel.rawAutoCompleteData.value?.let { (sessionOptional, id, lastWord) -> + val session = sessionOptional.orNull() + val bufferInfo = session?.bufferSyncer?.bufferInfo(id) + session?.networks?.let { networks -> + session.bufferSyncer?.bufferInfos()?.let { infos -> + if (bufferInfo?.type?.hasFlag(Buffer_Type.ChannelBuffer) == true) { + val network = networks[bufferInfo.networkId] + network?.ircChannel( + bufferInfo.bufferName + )?.let { ircChannel -> + val users = ircChannel.ircUsers() + val buffers = infos + .filter { + it.type.toInt() == Buffer_Type.ChannelBuffer.toInt() + }.mapNotNull { info -> + networks[info.networkId]?.let { info to it } + }.map { (info, network) -> + val channel = network.ircChannel(info.bufferName) ?: IrcChannel.NULL + AutoCompleteItem.ChannelItem( + info = info, + network = network.networkInfo(), + bufferStatus = when (channel) { + IrcChannel.NULL -> BufferStatus.OFFLINE + else -> BufferStatus.ONLINE + }, + description = channel.topic() + ) + } + val nicks = users.map { user -> + val userModes = ircChannel.userModes(user) + val prefixModes = network.prefixModes() + + val lowestMode = userModes.mapNotNull(prefixModes::indexOf).min() + ?: prefixModes.size + + AutoCompleteItem.UserItem( + user.nick(), + network.modesToPrefixes(userModes), + lowestMode, + user.realName(), + user.isAway(), + network.support("CASEMAPPING"), + Regex("[us]id(\\d+)").matchEntire(user.user())?.groupValues?.lastOrNull()?.let { + "https://www.irccloud.com/avatar-redirect/$it" + } + ) + } + + (nicks + buffers).filter { + it.name.trimStart(*IGNORED_CHARS) + .startsWith( + lastWord.first.trimStart(*IGNORED_CHARS), + ignoreCase = true + ) + }.sorted() + } + } else null + } + } + } ?: emptyList() + } + + fun autoComplete(reverse: Boolean = false) { + viewModel.lastWord.switchMap { it }.value?.let { originalWord -> + val previous = autoCompletionState + if (!originalWord.second.isEmpty()) { + val autoCompletedWords = autoCompleteDataFull() + if (previous != null && originalWord.first == previous.originalWord && originalWord.second.start == previous.range.start) { + val previousIndex = autoCompletedWords.indexOf(previous.completion) + val autoCompletedWord = if (previousIndex != -1) { + val change = if (reverse) -1 else +1 + val newIndex = (previousIndex + change + autoCompletedWords.size) % autoCompletedWords.size + + autoCompletedWords[newIndex] + } else { + autoCompletedWords.firstOrNull() + } + if (autoCompletedWord != null) { + val newState = AutoCompletionState( + previous.originalWord, + originalWord.second, + previous.completion, + autoCompletedWord + ) + autoCompletionState = newState + autocompleteListener?.invoke(newState) + } else { + autoCompletionState = null + } + } else { + val autoCompletedWord = autoCompletedWords.firstOrNull() + if (autoCompletedWord != null) { + val newState = AutoCompletionState( + originalWord.first, + originalWord.second, + null, + autoCompletedWord + ) + autoCompletionState = newState + autocompleteListener?.invoke(newState) + } else { + autoCompletionState = null + } + } + } + } + } + + companion object { + val IGNORED_CHARS = charArrayOf('-', '_', '[', ']', '{', '}', '|', '`', '^', '.', '\\', '@') + } +} diff --git a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/AutoCompletionState.kt b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/AutoCompletionState.kt new file mode 100644 index 000000000..91b1a8bd0 --- /dev/null +++ b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/AutoCompletionState.kt @@ -0,0 +1,10 @@ +package de.kuschku.quasseldroid.ui.chat.input + +import de.kuschku.quasseldroid.viewmodel.data.AutoCompleteItem + +data class AutoCompletionState( + val originalWord: String, + val range: IntRange, + val lastCompletion: AutoCompleteItem? = null, + val completion: AutoCompleteItem +) diff --git a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/ChatlineFragment.kt b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/ChatlineFragment.kt new file mode 100644 index 000000000..fcd2f8578 --- /dev/null +++ b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/ChatlineFragment.kt @@ -0,0 +1,177 @@ +package de.kuschku.quasseldroid.ui.chat.input + +import android.arch.lifecycle.Observer +import android.arch.lifecycle.ViewModelProviders +import android.os.Bundle +import android.support.v7.widget.AppCompatImageButton +import android.support.v7.widget.DefaultItemAnimator +import android.support.v7.widget.LinearLayoutManager +import android.support.v7.widget.RecyclerView +import android.text.SpannableString +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import butterknife.BindView +import butterknife.ButterKnife +import com.sothree.slidinguppanel.SlidingUpPanelLayout +import de.kuschku.libquassel.quassel.syncables.interfaces.IAliasManager +import de.kuschku.quasseldroid.R +import de.kuschku.quasseldroid.settings.AppearanceSettings +import de.kuschku.quasseldroid.settings.AutoCompleteSettings +import de.kuschku.quasseldroid.settings.MessageSettings +import de.kuschku.quasseldroid.util.helper.invoke +import de.kuschku.quasseldroid.util.helper.lineSequence +import de.kuschku.quasseldroid.util.helper.retint +import de.kuschku.quasseldroid.util.helper.visibleIf +import de.kuschku.quasseldroid.util.irc.format.IrcFormatDeserializer +import de.kuschku.quasseldroid.util.irc.format.IrcFormatSerializer +import de.kuschku.quasseldroid.util.service.ServiceBoundFragment +import de.kuschku.quasseldroid.viewmodel.EditorViewModel +import javax.inject.Inject + +class ChatlineFragment : ServiceBoundFragment() { + @BindView(R.id.chatline) + lateinit var chatline: RichEditText + + @BindView(R.id.formatting_toolbar) + lateinit var toolbar: RichToolbar + + @BindView(R.id.send) + lateinit var send: AppCompatImageButton + + @BindView(R.id.tab_complete) + lateinit var tabComplete: AppCompatImageButton + + @BindView(R.id.msg_history) + lateinit var messageHistory: RecyclerView + + @BindView(R.id.history_panel) + lateinit var historyPanel: SlidingUpPanelLayout + + @Inject + lateinit var autoCompleteSettings: AutoCompleteSettings + + @Inject + lateinit var messageSettings: MessageSettings + + @Inject + lateinit var appearanceSettings: AppearanceSettings + + @Inject + lateinit var ircFormatDeserializer: IrcFormatDeserializer + + @Inject + lateinit var ircFormatSerializer: IrcFormatSerializer + + lateinit var editorHelper: EditorHelper + + val panelSlideListener = object : SlidingUpPanelLayout.PanelSlideListener { + override fun onPanelSlide(panel: View?, slideOffset: Float) = Unit + + override fun onPanelStateChanged(panel: View?, + previousState: SlidingUpPanelLayout.PanelState?, + newState: SlidingUpPanelLayout.PanelState?) { + editorHelper.setMultiLine(newState == SlidingUpPanelLayout.PanelState.COLLAPSED) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + val view = LayoutInflater.from(activity).inflate(R.layout.fragment_chatline, container, false) + ButterKnife.bind(this, view) + + + val editorViewModel = ViewModelProviders.of(this).get(EditorViewModel::class.java) + editorViewModel.quasselViewModel.onNext(viewModel) + + val autoCompleteHelper = AutoCompleteHelper( + requireActivity(), + autoCompleteSettings, + messageSettings, + ircFormatDeserializer, + editorViewModel + ) + + editorHelper = EditorHelper( + requireActivity(), + chatline, + toolbar, + autoCompleteHelper, + autoCompleteSettings, + appearanceSettings + ) + + editorViewModel.lastWord.onNext(editorHelper.lastWord) + + if (autoCompleteSettings.prefix || autoCompleteSettings.auto) { + val autoCompleteLists = listOfNotNull<RecyclerView>( + view.findViewById(R.id.autocomplete_list), + view.findViewById(R.id.autocomplete_list_expanded) + ) + val autocompleteAdapter = AutoCompleteAdapter(messageSettings, chatline::autoComplete) + for (autoCompleteList in autoCompleteLists) { + autoCompleteList.layoutManager = LinearLayoutManager(activity) + autoCompleteList.itemAnimator = DefaultItemAnimator() + autoCompleteList.adapter = autocompleteAdapter + } + } + + messageHistory.itemAnimator = DefaultItemAnimator() + messageHistory.layoutManager = LinearLayoutManager(requireContext()) + val messageHistoryAdapter = MessageHistoryAdapter() + messageHistoryAdapter.setOnItemClickListener { text -> + editorHelper.replaceText(text) + historyPanel.panelState = SlidingUpPanelLayout.PanelState.COLLAPSED + } + messageHistory.adapter = messageHistoryAdapter + viewModel.recentlySentMessages_liveData.observe( + this, Observer(messageHistoryAdapter::submitList) + ) + + fun send() { + if (chatline.text.isNotBlank()) { + val lines = chatline.text.lineSequence().map { + it.toString() to ircFormatSerializer.toEscapeCodes(SpannableString(it)) + } + + viewModel.session { sessionOptional -> + val session = sessionOptional.orNull() + viewModel.buffer { bufferId -> + session?.bufferSyncer?.bufferInfo(bufferId)?.also { bufferInfo -> + val output = mutableListOf<IAliasManager.Command>() + for ((stripped, formatted) in lines) { + viewModel.addRecentlySentMessage(stripped) + session.aliasManager?.processInput(bufferInfo, formatted, output) + } + for (command in output) { + session.rpcHandler?.sendInput(command.buffer, command.message) + } + } + } + } + } + chatline.setText("") + } + + send.setOnClickListener { send() } + + tabComplete.visibleIf(autoCompleteSettings.button) + tabComplete.setOnClickListener { + autoCompleteHelper.autoComplete() + } + + toolbar.inflateMenu(R.menu.editor) + toolbar.menu.retint(requireActivity()) + toolbar.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_input_history -> { + historyPanel.panelState = SlidingUpPanelLayout.PanelState.EXPANDED + true + } + else -> false + } + } + + return view + } +} diff --git a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/Editor.kt b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/Editor.kt deleted file mode 100644 index cbcbd45ec..000000000 --- a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/Editor.kt +++ /dev/null @@ -1,610 +0,0 @@ -package de.kuschku.quasseldroid.ui.chat.input - -import android.arch.lifecycle.Observer -import android.graphics.Typeface -import android.support.annotation.ColorInt -import android.support.annotation.StringRes -import android.support.v4.app.FragmentActivity -import android.support.v7.app.AppCompatActivity -import android.support.v7.widget.* -import android.text.Editable -import android.text.InputType -import android.text.SpannableString -import android.text.TextWatcher -import android.text.style.ForegroundColorSpan -import android.text.style.StyleSpan -import android.view.* -import android.view.inputmethod.EditorInfo -import butterknife.BindView -import butterknife.ButterKnife -import de.kuschku.libquassel.protocol.Buffer_Type -import de.kuschku.libquassel.quassel.syncables.IrcChannel -import de.kuschku.libquassel.util.IrcUserUtils -import de.kuschku.libquassel.util.flag.hasFlag -import de.kuschku.libquassel.util.helpers.value -import de.kuschku.quasseldroid.R -import de.kuschku.quasseldroid.settings.AppearanceSettings -import de.kuschku.quasseldroid.settings.AutoCompleteSettings -import de.kuschku.quasseldroid.settings.MessageSettings -import de.kuschku.quasseldroid.ui.chat.ChatActivity -import de.kuschku.quasseldroid.util.helper.* -import de.kuschku.quasseldroid.util.irc.format.IrcFormatDeserializer -import de.kuschku.quasseldroid.util.ui.ColorChooserDialog -import de.kuschku.quasseldroid.util.ui.EditTextSelectionChange -import de.kuschku.quasseldroid.util.ui.TextDrawable -import de.kuschku.quasseldroid.viewmodel.QuasselViewModel -import de.kuschku.quasseldroid.viewmodel.data.AutoCompleteItem -import de.kuschku.quasseldroid.viewmodel.data.BufferStatus -import io.reactivex.subjects.BehaviorSubject - -class Editor( - // Contexts - activity: AppCompatActivity, - // LiveData - private val viewModel: QuasselViewModel, - // Views - val chatline: EditTextSelectionChange, - send: AppCompatImageButton, - tabComplete: AppCompatImageButton, - autoCompleteLists: List<RecyclerView>, - formattingToolbar: Toolbar, - // Helpers - private val ircFormatDeserializer: IrcFormatDeserializer, - // Settings - private val appearanceSettings: AppearanceSettings, - private val autoCompleteSettings: AutoCompleteSettings, - private val messageSettings: MessageSettings - // Listeners -) : ActionMenuView.OnMenuItemClickListener, Toolbar.OnMenuItemClickListener { - private var sendListener: ((Sequence<Pair<CharSequence, String>>) -> Unit)? = null - private var panelStateListener: ((Boolean) -> Unit)? = null - - fun setOnSendListener(listener: (Sequence<Pair<CharSequence, String>>) -> Unit) { - this.sendListener = listener - } - - fun setOnPanelStateListener(listener: (Boolean) -> Unit) { - this.panelStateListener = listener - } - - override fun onMenuItemClick(item: MenuItem?) = when (item?.itemId) { - R.id.action_input_history -> { - panelStateListener?.invoke(true) - true - } - else -> false - } - - private val senderColors = activity.theme.styledAttributes( - R.attr.senderColor0, R.attr.senderColor1, R.attr.senderColor2, R.attr.senderColor3, - R.attr.senderColor4, R.attr.senderColor5, R.attr.senderColor6, R.attr.senderColor7, - R.attr.senderColor8, R.attr.senderColor9, R.attr.senderColorA, R.attr.senderColorB, - R.attr.senderColorC, R.attr.senderColorD, R.attr.senderColorE, R.attr.senderColorF - ) { - IntArray(16) { - getColor(it, 0) - } - } - - private val lastWord = BehaviorSubject.createDefault(Pair("", IntRange.EMPTY)) - private val textWatcher = object : TextWatcher { - override fun afterTextChanged(s: Editable?) { - val previous = autocompletionState - val next = if (previous != null && s != null) { - val suffix = if (previous.range.start == 0) ": " else " " - val end = Math.min( - s.length, previous.range.start + previous.completion.name.length + suffix.length - ) - val sequence = if (end < previous.range.start) "" - else s.substring(previous.range.start, end) - if (sequence == previous.completion.name + suffix) { - previous.originalWord to (previous.range.start until end) - } else { - autocompletionState = null - s.lastWordIndices(chatline.selectionStart, onlyBeforeCursor = true)?.let { indices -> - s.substring(indices) to indices - } - } - } else { - s?.lastWordIndices(chatline.selectionStart, onlyBeforeCursor = true)?.let { indices -> - s.substring(indices) to indices - } - } - - lastWord.onNext(next ?: Pair("", IntRange.EMPTY)) - - updateButtons(chatline.selection) - } - - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit - } - - val formatHandler = FormatHandler(chatline) - - private var autocompletionState: ChatActivity.AutoCompletionState? = null - - @BindView(R.id.action_format_bold) - lateinit var boldButton: View - - @BindView(R.id.action_format_italic) - lateinit var italicButton: View - - @BindView(R.id.action_format_underline) - lateinit var underlineButton: View - - @BindView(R.id.action_format_strikethrough) - lateinit var strikethroughButton: View - - @BindView(R.id.action_format_monospace) - lateinit var monospaceButton: View - - @BindView(R.id.action_format_foreground) - lateinit var foregroundButton: View - - @BindView(R.id.action_format_foreground_preview) - lateinit var foregroundButtonPreview: View - - @BindView(R.id.action_format_background) - lateinit var backgroundButton: View - - @BindView(R.id.action_format_background_preview) - lateinit var backgroundButtonPreview: View - - @BindView(R.id.action_format_clear) - lateinit var clearButton: View - - init { - send.setOnClickListener { - send() - } - - chatline.imeOptions = when (appearanceSettings.inputEnter) { - AppearanceSettings.InputEnterMode.EMOJI -> listOf( - EditorInfo.IME_ACTION_NONE, - EditorInfo.IME_FLAG_NO_EXTRACT_UI - ) - AppearanceSettings.InputEnterMode.SEND -> listOf( - EditorInfo.IME_ACTION_SEND, - EditorInfo.IME_FLAG_NO_EXTRACT_UI - ) - }.fold(0, Int::or) - - val autocompleteAdapter = AutoCompleteAdapter( - messageSettings, - // This is still broken when mixing tab complete and UI auto complete - formatHandler::autoComplete - ) - - viewModel.autoCompleteData.toLiveData().observe(activity, Observer { - val query = it?.first ?: "" - val shouldShowResults = (autoCompleteSettings.auto && query.length >= 3) || - (autoCompleteSettings.prefix && query.startsWith('@')) || - (autoCompleteSettings.prefix && query.startsWith('#')) - val list = if (shouldShowResults) it?.second.orEmpty() else emptyList() - autocompleteAdapter.submitList(list.map { - if (it is AutoCompleteItem.UserItem) { - val nickName = it.nick - val senderColorIndex = IrcUserUtils.senderColor(nickName) - val rawInitial = nickName.trimStart('-', - '_', - '[', - ']', - '{', - '}', - '|', - '`', - '^', - '.', - '\\') - .firstOrNull() ?: nickName.firstOrNull() - val initial = rawInitial?.toUpperCase().toString() - val senderColor = senderColors[senderColorIndex] - - fun formatNick(nick: CharSequence): CharSequence { - val spannableString = SpannableString(nick) - spannableString.setSpan( - ForegroundColorSpan(senderColor), - 0, - nick.length, - SpannableString.SPAN_INCLUSIVE_EXCLUSIVE - ) - spannableString.setSpan( - StyleSpan(Typeface.BOLD), - 0, - nick.length, - SpannableString.SPAN_INCLUSIVE_EXCLUSIVE - ) - return spannableString - } - - it.copy( - displayNick = formatNick(it.nick), - fallbackDrawable = TextDrawable.builder().buildRound(initial, senderColor), - modes = when (messageSettings.showPrefix) { - MessageSettings.ShowPrefixMode.ALL -> - it.modes - else -> - it.modes.substring(0, Math.min(it.modes.length, 1)) - }, - realname = ircFormatDeserializer.formatString( - activity, it.realname.toString(), messageSettings.colorizeMirc - ) - ) - } else { - it - } - }) - }) - - if (autoCompleteSettings.prefix || autoCompleteSettings.auto) { - for (autoCompleteList in autoCompleteLists) { - autoCompleteList.layoutManager = LinearLayoutManager(activity) - autoCompleteList.itemAnimator = DefaultItemAnimator() - autoCompleteList.adapter = autocompleteAdapter - } - } - - if (autoCompleteSettings.doubleTap) { - val gestureDetector = GestureDetector( - chatline.context, object : GestureDetector.SimpleOnGestureListener() { - override fun onDoubleTap(e: MotionEvent?): Boolean { - autoComplete() - return true - } - - override fun onDoubleTapEvent(e: MotionEvent?): Boolean { - return true - } - }) - chatline.setOnTouchListener { _, event -> - gestureDetector.onTouchEvent(event) - } - } - - tabComplete.visibleIf(autoCompleteSettings.button) - tabComplete.setOnClickListener { - autoComplete() - } - - viewModel.lastWord.onNext(lastWord) - - activity.menuInflater.inflate(R.menu.editor, formattingToolbar.menu) - formattingToolbar.menu.retint(activity) - formattingToolbar.setOnMenuItemClickListener(this) - - ButterKnife.bind(this, formattingToolbar) - - boldButton.setOnClickListener { - formatHandler.toggleBold(chatline.selection) - updateButtons(chatline.selection) - } - TooltipCompat.setTooltipText(boldButton, boldButton.contentDescription) - - italicButton.setOnClickListener { - formatHandler.toggleItalic(chatline.selection) - updateButtons(chatline.selection) - } - TooltipCompat.setTooltipText(italicButton, italicButton.contentDescription) - - underlineButton.setOnClickListener { - formatHandler.toggleUnderline(chatline.selection) - updateButtons(chatline.selection) - } - TooltipCompat.setTooltipText(underlineButton, underlineButton.contentDescription) - - strikethroughButton.setOnClickListener { - formatHandler.toggleStrikethrough(chatline.selection) - updateButtons(chatline.selection) - } - TooltipCompat.setTooltipText(strikethroughButton, strikethroughButton.contentDescription) - - monospaceButton.setOnClickListener { - formatHandler.toggleMonospace(chatline.selection) - updateButtons(chatline.selection) - } - TooltipCompat.setTooltipText(monospaceButton, monospaceButton.contentDescription) - - foregroundButton.setOnClickListener { - showColorChooser( - activity, - R.string.label_foreground, - formatHandler.foregroundColor(chatline.selection), - formatHandler.defaultForegroundColor - ) { color -> - formatHandler.toggleForeground(chatline.selection, color, - formatHandler.mircColorMap[color]) - updateButtons(chatline.selection) - } - } - TooltipCompat.setTooltipText(foregroundButton, foregroundButton.contentDescription) - - backgroundButton.setOnClickListener { - showColorChooser( - activity, - R.string.label_background, - formatHandler.backgroundColor(chatline.selection), - formatHandler.defaultBackgroundColor - ) { color -> - formatHandler.toggleBackground(chatline.selection, color, - formatHandler.mircColorMap[color]) - updateButtons(chatline.selection) - } - } - TooltipCompat.setTooltipText(backgroundButton, backgroundButton.contentDescription) - - clearButton.setOnClickListener { - formatHandler.clearFormatting(chatline.selection) - updateButtons(chatline.selection) - } - TooltipCompat.setTooltipText(clearButton, clearButton.contentDescription) - - chatline.setOnEditorActionListener { _, actionId, event: KeyEvent? -> - when (actionId) { - EditorInfo.IME_ACTION_SEND, - EditorInfo.IME_ACTION_DONE -> { - if (event?.action == KeyEvent.ACTION_DOWN) send() - true - } - else -> false - } - } - - chatline.setOnKeyListener { _, keyCode, event: KeyEvent? -> - if (event?.action == KeyEvent.ACTION_DOWN) { - if (event.isCtrlPressed && !event.isAltPressed) when (keyCode) { - KeyEvent.KEYCODE_B -> { - formatHandler.toggleBold(chatline.selection) - updateButtons(chatline.selection) - true - } - KeyEvent.KEYCODE_I -> { - formatHandler.toggleItalic(chatline.selection) - updateButtons(chatline.selection) - true - } - KeyEvent.KEYCODE_U -> { - formatHandler.toggleUnderline(chatline.selection) - updateButtons(chatline.selection) - true - } - else -> false - } else when (keyCode) { - KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_NUMPAD_ENTER -> if (event.isShiftPressed) { - false - } else { - send() - true - } - KeyEvent.KEYCODE_TAB -> { - if (!event.isAltPressed && !event.isCtrlPressed) { - autoComplete(event.isShiftPressed) - true - } else { - false - } - } - else -> false - } - } else if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) { - !(event?.isShiftPressed ?: false) - } else { - false - } - } - } - - private fun showColorChooser( - activity: FragmentActivity, - @StringRes title: Int, - @ColorInt preselect: Int?, - @ColorInt default: Int, - f: (Int?) -> Unit - ) { - var selectedColor: Int? = preselect - ColorChooserDialog.Builder(chatline.context, title) - .customColors(intArrayOf( - formatHandler.mircColors[0], - formatHandler.mircColors[1], - formatHandler.mircColors[2], - formatHandler.mircColors[3], - formatHandler.mircColors[4], - formatHandler.mircColors[5], - formatHandler.mircColors[6], - formatHandler.mircColors[7], - formatHandler.mircColors[8], - formatHandler.mircColors[9], - formatHandler.mircColors[10], - formatHandler.mircColors[11], - formatHandler.mircColors[12], - formatHandler.mircColors[13], - formatHandler.mircColors[14], - formatHandler.mircColors[15] - ), null) - .doneButton(R.string.label_select) - .cancelButton(R.string.label_reset) - .backButton(R.string.label_back) - .customButton(R.string.label_colors_custom) - .presetsButton(R.string.label_colors_mirc) - .preselect(preselect ?: default) - .dynamicButtonColor(false) - .allowUserColorInputAlpha(false) - .callback(object : ColorChooserDialog.ColorCallback { - override fun onColorReset(dialog: ColorChooserDialog) { - selectedColor = null - } - - override fun onColorSelection(dialog: ColorChooserDialog, color: Int) { - selectedColor = color - } - - override fun onColorChooserDismissed(dialog: ColorChooserDialog) { - f(selectedColor) - } - }) - .show(activity) - } - - fun updateButtons(selection: IntRange) { - boldButton.isSelected = formatHandler.isBold(selection) - italicButton.isSelected = formatHandler.isItalic(selection) - underlineButton.isSelected = formatHandler.isUnderline(selection) - strikethroughButton.isSelected = formatHandler.isStrikethrough(selection) - monospaceButton.isSelected = formatHandler.isMonospace(selection) - foregroundButtonPreview.setBackgroundColor(formatHandler.foregroundColor(selection) - ?: formatHandler.defaultForegroundColor) - backgroundButtonPreview.setBackgroundColor(formatHandler.backgroundColor(selection) - ?: formatHandler.defaultBackgroundColor) - } - - fun onStart() { - chatline.addTextChangedListener(textWatcher) - chatline.setSelectionChangeListener(::updateButtons) - } - - fun onStop() { - chatline.removeTextChangedListener(textWatcher) - chatline.removeSelectionChangeListener() - } - - private fun send() { - if (rawText.isNotBlank()) { - sendListener?.invoke(strippedText.lineSequence().zip(formattedText)) - } - chatline.setText("") - } - - fun setMultiLine(enabled: Boolean) { - val selectionStart = chatline.selectionStart - val selectionEnd = chatline.selectionEnd - - if (enabled) { - chatline.inputType = chatline.inputType and InputType.TYPE_TEXT_FLAG_MULTI_LINE.inv() - } else { - chatline.inputType = chatline.inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE - } - - chatline.setSelection(selectionStart, selectionEnd) - } - - private fun autoCompleteDataFull(): List<AutoCompleteItem> { - return viewModel.rawAutoCompleteData.value?.let { (sessionOptional, id, lastWord) -> - val session = sessionOptional.orNull() - val bufferInfo = session?.bufferSyncer?.bufferInfo(id) - session?.networks?.let { networks -> - session.bufferSyncer?.bufferInfos()?.let { infos -> - if (bufferInfo?.type?.hasFlag(Buffer_Type.ChannelBuffer) == true) { - val network = networks[bufferInfo.networkId] - network?.ircChannel( - bufferInfo.bufferName - )?.let { ircChannel -> - val users = ircChannel.ircUsers() - val buffers = infos - .filter { - it.type.toInt() == Buffer_Type.ChannelBuffer.toInt() - }.mapNotNull { info -> - networks[info.networkId]?.let { info to it } - }.map { (info, network) -> - val channel = network.ircChannel(info.bufferName) ?: IrcChannel.NULL - AutoCompleteItem.ChannelItem( - info = info, - network = network.networkInfo(), - bufferStatus = when (channel) { - IrcChannel.NULL -> BufferStatus.OFFLINE - else -> BufferStatus.ONLINE - }, - description = channel.topic() - ) - } - val nicks = users.map { user -> - val userModes = ircChannel.userModes(user) - val prefixModes = network.prefixModes() - - val lowestMode = userModes.mapNotNull(prefixModes::indexOf).min() - ?: prefixModes.size - - AutoCompleteItem.UserItem( - user.nick(), - network.modesToPrefixes(userModes), - lowestMode, - user.realName(), - user.isAway(), - network.support("CASEMAPPING"), - Regex("[us]id(\\d+)").matchEntire(user.user())?.groupValues?.lastOrNull()?.let { - "https://www.irccloud.com/avatar-redirect/$it" - } - ) - } - - val ignoredStartingCharacters = charArrayOf( - '-', '_', '[', ']', '{', '}', '|', '`', '^', '.', '\\', '@' - ) - - (nicks + buffers).filter { - it.name.trimStart(*ignoredStartingCharacters) - .startsWith( - lastWord.first.trimStart(*ignoredStartingCharacters), - ignoreCase = true - ) - }.sorted() - } - } else null - } - } - } ?: emptyList() - } - - private fun autoComplete(reverse: Boolean = false) { - val originalWord = lastWord.value - - val previous = autocompletionState - if (!originalWord.second.isEmpty()) { - val autoCompletedWords = autoCompleteDataFull() - if (previous != null && lastWord.value.first == previous.originalWord && lastWord.value.second.start == previous.range.start) { - val previousIndex = autoCompletedWords.indexOf(previous.completion) - val autoCompletedWord = if (previousIndex != -1) { - val change = if (reverse) -1 else +1 - val newIndex = (previousIndex + change + autoCompletedWords.size) % autoCompletedWords.size - - autoCompletedWords[newIndex] - } else { - autoCompletedWords.firstOrNull() - } - if (autoCompletedWord != null) { - val newState = ChatActivity.AutoCompletionState( - previous.originalWord, - originalWord.second, - previous.completion, - autoCompletedWord - ) - autocompletionState = newState - formatHandler.autoComplete(newState) - } else { - autocompletionState = null - } - } else { - val autoCompletedWord = autoCompletedWords.firstOrNull() - if (autoCompletedWord != null) { - val newState = ChatActivity.AutoCompletionState( - originalWord.first, - originalWord.second, - null, - autoCompletedWord - ) - autocompletionState = newState - formatHandler.autoComplete(newState) - } else { - autocompletionState = null - } - } - } - } - - val formattedText: Sequence<String> - get() = formatHandler.formattedText - val rawText: CharSequence - get() = formatHandler.rawText - val strippedText: CharSequence - get() = formatHandler.strippedText -} diff --git a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/EditorHelper.kt b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/EditorHelper.kt new file mode 100644 index 000000000..a16dd2f1e --- /dev/null +++ b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/EditorHelper.kt @@ -0,0 +1,206 @@ +package de.kuschku.quasseldroid.ui.chat.input + +import android.support.annotation.ColorInt +import android.support.annotation.StringRes +import android.support.v4.app.FragmentActivity +import android.text.Editable +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.inputmethod.EditorInfo +import de.kuschku.quasseldroid.R +import de.kuschku.quasseldroid.settings.AppearanceSettings +import de.kuschku.quasseldroid.settings.AutoCompleteSettings +import de.kuschku.quasseldroid.util.helper.lastWordIndices +import de.kuschku.quasseldroid.util.helper.styledAttributes +import de.kuschku.quasseldroid.util.ui.ColorChooserDialog +import io.reactivex.subjects.BehaviorSubject + +class EditorHelper( + private val activity: FragmentActivity, + private val editText: RichEditText, + private val toolbar: RichToolbar, + private val autoCompleteHelper: AutoCompleteHelper, + autoCompleteSettings: AutoCompleteSettings, + appearanceSettings: AppearanceSettings +) { + private var enterListener: (() -> Unit)? = null + + private val mircColors = editText.context.theme.styledAttributes( + R.attr.mircColor00, R.attr.mircColor01, R.attr.mircColor02, R.attr.mircColor03, + R.attr.mircColor04, R.attr.mircColor05, R.attr.mircColor06, R.attr.mircColor07, + R.attr.mircColor08, R.attr.mircColor09, R.attr.mircColor10, R.attr.mircColor11, + R.attr.mircColor12, R.attr.mircColor13, R.attr.mircColor14, R.attr.mircColor15 + ) { + IntArray(length(), { getColor(it, 0) }) + } + + private val defaultForegroundColor = editText.context.theme.styledAttributes(R.attr.colorForeground) { + getColor(0, 0) + } + + private val defaultBackgroundColor = editText.context.theme.styledAttributes(R.attr.colorBackground) { + getColor(0, 0) + } + + val lastWord = BehaviorSubject.createDefault(Pair("", IntRange.EMPTY)) + private val textWatcher = object : TextWatcher { + override fun afterTextChanged(s: Editable?) { + val previous = autoCompleteHelper.autoCompletionState + val next = if (previous != null && s != null) { + val suffix = if (previous.range.start == 0) ": " else " " + val end = Math.min( + s.length, previous.range.start + previous.completion.name.length + suffix.length + ) + val sequence = if (end < previous.range.start) "" + else s.substring(previous.range.start, end) + if (sequence == previous.completion.name + suffix) { + previous.originalWord to (previous.range.start until end) + } else { + autoCompleteHelper.autoCompletionState = null + s.lastWordIndices(editText.selectionStart, onlyBeforeCursor = true)?.let { indices -> + s.substring(indices) to indices + } + } + } else { + s?.lastWordIndices(editText.selectionStart, onlyBeforeCursor = true)?.let { indices -> + s.substring(indices) to indices + } + } + + lastWord.onNext(next ?: Pair("", IntRange.EMPTY)) + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + } + + init { + toolbar.setFormattingListener(object : RichToolbar.FormattingListener { + override fun onBold() = editText.toggleBold() + override fun onItalic() = editText.toggleItalic() + override fun onUnderline() = editText.toggleUnderline() + override fun onStrikethrough() = editText.toggleStrikethrough() + override fun onMonospace() = editText.toggleMonospace() + override fun onForeground() = showColorChooser(R.string.label_foreground, + editText.foregroundColor(), + defaultForegroundColor) { + editText.toggleForeground(color = it) + } + + override fun onBackground() = showColorChooser(R.string.label_background, + editText.backgroundColor(), + defaultBackgroundColor) { + editText.toggleBackground(color = it) + } + + override fun onClear() = editText.clearFormatting() + }) + autoCompleteHelper.setAutocompleteListener(editText::autoComplete) + editText.setFormattingListener { bold, italic, underline, strikethrough, monospace, foreground, background -> + toolbar.update( + bold, + italic, + underline, + strikethrough, + monospace, + foreground ?: defaultForegroundColor, + background ?: defaultBackgroundColor + ) + } + editText.addTextChangedListener(textWatcher) + editText.setOnKeyListener { _, keyCode, event: KeyEvent? -> + if (event?.action == KeyEvent.ACTION_DOWN) { + if (event.isCtrlPressed && !event.isAltPressed) when (keyCode) { + KeyEvent.KEYCODE_B -> { + editText.toggleBold() + true + } + KeyEvent.KEYCODE_I -> { + editText.toggleItalic() + true + } + KeyEvent.KEYCODE_U -> { + editText.toggleUnderline() + true + } + else -> false + } else when (keyCode) { + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER -> if (event.isShiftPressed) { + false + } else { + enterListener?.invoke() + true + } + KeyEvent.KEYCODE_TAB -> { + if (!event.isAltPressed && !event.isCtrlPressed) { + autoCompleteHelper.autoComplete(event.isShiftPressed) + true + } else { + false + } + } + else -> false + } + } else if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) { + !(event?.isShiftPressed ?: false) + } else { + false + } + } + + if (autoCompleteSettings.doubleTap) { + editText.setDoubleClickListener { + autoCompleteHelper.autoComplete() + } + } + + editText.imeOptions = when (appearanceSettings.inputEnter) { + AppearanceSettings.InputEnterMode.EMOJI -> listOf( + EditorInfo.IME_ACTION_NONE, + EditorInfo.IME_FLAG_NO_EXTRACT_UI + ) + AppearanceSettings.InputEnterMode.SEND -> listOf( + EditorInfo.IME_ACTION_SEND, + EditorInfo.IME_FLAG_NO_EXTRACT_UI + ) + }.fold(0, Int::or) + } + + fun setMultiLine(enabled: Boolean) = editText.setMultiLine(enabled) + + fun replaceText(text: CharSequence?) = editText.replace(text) + + private fun showColorChooser( + @StringRes title: Int, + @ColorInt preselect: Int?, + @ColorInt default: Int, + f: (Int?) -> Unit + ) { + var selectedColor: Int? = preselect + ColorChooserDialog.Builder(editText.context, title) + .customColors(mircColors, null) + .doneButton(R.string.label_select) + .cancelButton(R.string.label_reset) + .backButton(R.string.label_back) + .customButton(R.string.label_colors_custom) + .presetsButton(R.string.label_colors_mirc) + .preselect(preselect ?: default) + .dynamicButtonColor(false) + .allowUserColorInputAlpha(false) + .callback(object : ColorChooserDialog.ColorCallback { + override fun onColorReset(dialog: ColorChooserDialog) { + selectedColor = null + } + + override fun onColorSelection(dialog: ColorChooserDialog, color: Int) { + selectedColor = color + } + + override fun onColorChooserDismissed(dialog: ColorChooserDialog) { + f(selectedColor) + } + }) + .show(activity) + } +} diff --git a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/FormatHandler.kt b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/RichEditText.kt similarity index 51% rename from app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/FormatHandler.kt rename to app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/RichEditText.kt index af1987c58..be0b235b7 100644 --- a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/FormatHandler.kt +++ b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/RichEditText.kt @@ -1,22 +1,20 @@ package de.kuschku.quasseldroid.ui.chat.input +import android.content.Context import android.graphics.Typeface import android.support.annotation.ColorInt -import android.text.Editable -import android.text.SpannableString +import android.text.InputType import android.text.Spanned import android.text.style.* -import android.widget.EditText +import android.util.AttributeSet import de.kuschku.quasseldroid.R -import de.kuschku.quasseldroid.ui.chat.ChatActivity import de.kuschku.quasseldroid.util.helper.* -import de.kuschku.quasseldroid.util.irc.format.IrcFormatSerializer import de.kuschku.quasseldroid.util.irc.format.spans.* +import de.kuschku.quasseldroid.util.ui.DoubleClickHelper +import de.kuschku.quasseldroid.util.ui.EditTextSelectionChange -class FormatHandler( - private val editText: EditText -) { - val mircColors = editText.context.theme.styledAttributes( +class RichEditText : EditTextSelectionChange { + private val mircColors = this.context.theme.styledAttributes( R.attr.mircColor00, R.attr.mircColor01, R.attr.mircColor02, R.attr.mircColor03, R.attr.mircColor04, R.attr.mircColor05, R.attr.mircColor06, R.attr.mircColor07, R.attr.mircColor08, R.attr.mircColor09, R.attr.mircColor10, R.attr.mircColor11, @@ -43,45 +41,40 @@ class FormatHandler( R.attr.mircColor92, R.attr.mircColor93, R.attr.mircColor94, R.attr.mircColor95, R.attr.mircColor96, R.attr.mircColor97, R.attr.mircColor98 ) { - (0..98).map { getColor(it, 0) } + IntArray(length(), { getColor(it, 0) }) } - val mircColorMap = mircColors.withIndex().map { (key, value) -> key to value }.toMap() + private val mircColorMap = mircColors.withIndex().map { (key, value) -> key to value }.toMap() - val defaultForegroundColor = editText.context.theme.styledAttributes(R.attr.colorForeground) { - getColor(0, 0) + private var formattingListener: ((Boolean, Boolean, Boolean, Boolean, Boolean, Int?, Int?) -> Unit)? = null + + private val doubleClickHelper = DoubleClickHelper(this) + + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : + super(context, attrs, defStyleAttr) + + init { + setSelectionChangeListener(this::selectedFormattingChanged) + setOnTouchListener(doubleClickHelper) } - val defaultBackgroundColor = editText.context.theme.styledAttributes(R.attr.colorBackground) { - getColor(0, 0) + fun setFormattingListener( + listener: ((Boolean, Boolean, Boolean, Boolean, Boolean, Int?, Int?) -> Unit)?) { + this.formattingListener = listener } - private val serializer = IrcFormatSerializer(editText.context) - val formattedText: Sequence<String> - get() = editText.text.lineSequence().map { serializer.toEscapeCodes(SpannableString(it)) } - val rawText: CharSequence - get() = editText.text - val strippedText: CharSequence - get() = editText.text.let { - val text = SpannableString(it) - val toRemove = mutableListOf<Any>() - for (span in text.getSpans(0, text.length, Any::class.java)) { - if ((text.getSpanFlags(span) and Spanned.SPAN_COMPOSING) != 0) { - toRemove.add(span) - } - } - for (span in toRemove) { - text.removeSpan(span) - } - text - } + fun setDoubleClickListener(listener: (() -> Unit)?) { + this.doubleClickHelper.doubleClickListener = listener + } - fun isBold(range: IntRange) = editText.text.hasSpans<StyleSpan>(range) { + fun isBold(range: IntRange = selection) = this.text.hasSpans<StyleSpan>(range) { it.style == Typeface.BOLD || it.style == Typeface.BOLD_ITALIC } - fun toggleBold(range: IntRange, createNew: Boolean = true) { + fun toggleBold(range: IntRange = selection, createNew: Boolean = true) { val bold = isBold(range) - editText.text.removeSpans<StyleSpan, IrcBoldSpan>(range) { span -> + this.text.removeSpans<StyleSpan, IrcBoldSpan>(range) { span -> when { span is IrcBoldSpan -> span span.style == Typeface.BOLD -> IrcBoldSpan() @@ -90,20 +83,21 @@ class FormatHandler( } if (!bold && createNew) { - editText.text.setSpan( + this.text.setSpan( IrcBoldSpan(), range.start, range.endInclusive + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE ) } + selectedFormattingChanged() } - fun isItalic(range: IntRange) = editText.text.hasSpans<StyleSpan>(range) { + fun isItalic(range: IntRange = selection) = this.text.hasSpans<StyleSpan>(range) { it.style == Typeface.ITALIC || it.style == Typeface.BOLD_ITALIC } - fun toggleItalic(range: IntRange, createNew: Boolean = true) { + fun toggleItalic(range: IntRange = selection, createNew: Boolean = true) { val italic = isItalic(range) - editText.text.removeSpans<StyleSpan, IrcItalicSpan>(range) { span -> + this.text.removeSpans<StyleSpan, IrcItalicSpan>(range) { span -> when { span is IrcItalicSpan -> span span.style == Typeface.ITALIC -> IrcItalicSpan() @@ -112,18 +106,19 @@ class FormatHandler( } if (!italic && createNew) { - editText.text.setSpan( + this.text.setSpan( IrcItalicSpan(), range.start, range.endInclusive + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE ) } + selectedFormattingChanged() } - fun isUnderline(range: IntRange) = editText.text.hasSpans<UnderlineSpan>(range) + fun isUnderline(range: IntRange = selection) = this.text.hasSpans<UnderlineSpan>(range) - fun toggleUnderline(range: IntRange, createNew: Boolean = true) { + fun toggleUnderline(range: IntRange = selection, createNew: Boolean = true) { val underline = isUnderline(range) - editText.text.removeSpans<UnderlineSpan, IrcUnderlineSpan>(range) { span -> + this.text.removeSpans<UnderlineSpan, IrcUnderlineSpan>(range) { span -> when (span) { is IrcUnderlineSpan -> span else -> IrcUnderlineSpan() @@ -131,18 +126,19 @@ class FormatHandler( } if (!underline && createNew) { - editText.text.setSpan( + this.text.setSpan( IrcUnderlineSpan(), range.start, range.endInclusive + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE ) } + selectedFormattingChanged() } - fun isStrikethrough(range: IntRange) = editText.text.hasSpans<StrikethroughSpan>(range) + fun isStrikethrough(range: IntRange = selection) = this.text.hasSpans<StrikethroughSpan>(range) - fun toggleStrikethrough(range: IntRange, createNew: Boolean = true) { + fun toggleStrikethrough(range: IntRange = selection, createNew: Boolean = true) { val strikethrough = isStrikethrough(range) - editText.text.removeSpans<StrikethroughSpan, IrcStrikethroughSpan>(range) { span -> + this.text.removeSpans<StrikethroughSpan, IrcStrikethroughSpan>(range) { span -> when (span) { is IrcStrikethroughSpan -> span else -> IrcStrikethroughSpan() @@ -150,21 +146,22 @@ class FormatHandler( } if (!strikethrough && createNew) { - editText.text.setSpan( + this.text.setSpan( IrcStrikethroughSpan(), range.start, range.endInclusive + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE ) } + selectedFormattingChanged() } - fun isMonospace(range: IntRange) = editText.text.hasSpans<TypefaceSpan>(range) { + fun isMonospace(range: IntRange = selection) = this.text.hasSpans<TypefaceSpan>(range) { it.family == "monospace" } - fun toggleMonospace(range: IntRange, createNew: Boolean = true) { + fun toggleMonospace(range: IntRange = selection, createNew: Boolean = true) { val monospace = isMonospace(range) - editText.text.removeSpans<TypefaceSpan, IrcMonospaceSpan>(range) { span -> + this.text.removeSpans<TypefaceSpan, IrcMonospaceSpan>(range) { span -> when { span is IrcMonospaceSpan -> span span.family == "monospace" -> IrcMonospaceSpan() @@ -173,16 +170,20 @@ class FormatHandler( } if (!monospace && createNew) { - editText.text.setSpan( + this.text.setSpan( IrcMonospaceSpan(), range.start, range.endInclusive + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE ) } + selectedFormattingChanged() } - fun foregroundColors(range: IntRange) = editText.text.spans<ForegroundColorSpan>(range) - fun foregroundColor(range: IntRange) = foregroundColors(range).singleOrNull()?.foregroundColor - fun toggleForeground(range: IntRange, @ColorInt color: Int? = null, mircColor: Int? = null) { - editText.text.removeSpans<ForegroundColorSpan, IrcForegroundColorSpan<*>>(range) { span -> + fun foregroundColors(range: IntRange = selection) = this.text.spans<ForegroundColorSpan>(range) + fun foregroundColor(range: IntRange = selection) = + foregroundColors(range).singleOrNull()?.foregroundColor + + fun toggleForeground(range: IntRange = selection, @ColorInt color: Int? = null, + mircColor: Int? = null) { + this.text.removeSpans<ForegroundColorSpan, IrcForegroundColorSpan<*>>(range) { span -> val mirc = mircColorMap[span.foregroundColor] when { span is IrcForegroundColorSpan<*> -> span @@ -193,14 +194,14 @@ class FormatHandler( if (color != null) { if (mircColor != null) { - editText.text.setSpan( + this.text.setSpan( IrcForegroundColorSpan.MIRC(mircColor, color), range.start, range.last + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE ) } else { - editText.text.setSpan( + this.text.setSpan( IrcForegroundColorSpan.HEX(color), range.start, range.last + 1, @@ -208,12 +209,16 @@ class FormatHandler( ) } } + selectedFormattingChanged() } - fun backgroundColors(range: IntRange) = editText.text.spans<BackgroundColorSpan>(range) - fun backgroundColor(range: IntRange) = backgroundColors(range).singleOrNull()?.backgroundColor - fun toggleBackground(range: IntRange, @ColorInt color: Int? = null, mircColor: Int? = null) { - editText.text.removeSpans<BackgroundColorSpan, IrcBackgroundColorSpan<*>>(range) { span -> + fun backgroundColors(range: IntRange = selection) = this.text.spans<BackgroundColorSpan>(range) + fun backgroundColor(range: IntRange = selection) = + backgroundColors(range).singleOrNull()?.backgroundColor + + fun toggleBackground(range: IntRange = selection, @ColorInt color: Int? = null, + mircColor: Int? = null) { + this.text.removeSpans<BackgroundColorSpan, IrcBackgroundColorSpan<*>>(range) { span -> val mirc = mircColorMap[span.backgroundColor] when { span is IrcBackgroundColorSpan<*> -> span @@ -224,14 +229,14 @@ class FormatHandler( if (color != null) { if (mircColor != null) { - editText.text.setSpan( + this.text.setSpan( IrcBackgroundColorSpan.MIRC(mircColor, color), range.start, range.last + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE ) } else { - editText.text.setSpan( + this.text.setSpan( IrcBackgroundColorSpan.HEX(color), range.start, range.last + 1, @@ -239,9 +244,10 @@ class FormatHandler( ) } } + selectedFormattingChanged() } - fun clearFormatting(range: IntRange) { + fun clearFormatting(range: IntRange = selection) { toggleBold(range, false) toggleItalic(range, false) toggleUnderline(range, false) @@ -251,68 +257,21 @@ class FormatHandler( toggleBackground(range, null, null) } - private inline fun <reified U> Spanned.spans(range: IntRange) = - getSpans(range.start, range.endInclusive + 1, U::class.java).filter { - getSpanFlags(it) and Spanned.SPAN_COMPOSING == 0 && - (getSpanEnd(it) != range.start || - getSpanFlags(it) and 0x02 != 0) - } - - private inline fun <reified U> Spanned.spans(range: IntRange, f: (U) -> Boolean) = - getSpans(range.start, range.last + 1, U::class.java).filter { - f(it) && - getSpanFlags(it) and Spanned.SPAN_COMPOSING == 0 && - (getSpanEnd(it) != range.start || - getSpanFlags(it) and 0x02 != 0) - } + fun setMultiLine(enabled: Boolean) { + val selectionStart = selectionStart + val selectionEnd = selectionEnd - private inline fun <reified U> Spanned.hasSpans(range: IntRange) = - getSpans(range.start, range.endInclusive + 1, U::class.java).any { - getSpanFlags(it) and Spanned.SPAN_COMPOSING == 0 && - (getSpanEnd(it) != range.start || - getSpanFlags(it) and 0x02 != 0) - } - - private inline fun <reified U> Spanned.hasSpans(range: IntRange, f: (U) -> Boolean) = - getSpans(range.start, range.last + 1, U::class.java).any { - f(it) && - getSpanFlags(it) and Spanned.SPAN_COMPOSING == 0 && - (getSpanEnd(it) != range.start || - getSpanFlags(it) and 0x02 != 0) + inputType = if (enabled) { + inputType and InputType.TYPE_TEXT_FLAG_MULTI_LINE.inv() + } else { + inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE } - private inline fun <reified U, T> Editable.removeSpans( - range: IntRange, removeInvalid: Boolean = false, f: (U) -> T? - ) where T : Copyable<T> { - - for (raw in getSpans<U>(range.start, range.endInclusive + 1, U::class.java)) { - val spanFlags = getSpanFlags(raw) - if (spanFlags and Spanned.SPAN_COMPOSING != 0) continue - - val spanEnd = getSpanEnd(raw) - val spanStart = getSpanStart(raw) - - val span = f(raw) - if (span == null) { - if (removeInvalid) - removeSpan(raw) - } else { - removeSpan(raw) - - for (spanRange in spanStart until spanEnd without range) { - setSpan( - span.copy(), - spanRange.start, - spanRange.endInclusive + 1, - (spanFlags and 0x03.inv()) or 0x01 - ) - } - } - } + setSelection(selectionStart, selectionEnd) } fun autoComplete(text: CharSequence) { - val range = editText.text.lastWordIndices(editText.selection.start, true) + val range = this.text.lastWordIndices(this.selection.start, true) val replacement = if (range?.start == 0) { "$text: " } else { @@ -320,36 +279,48 @@ class FormatHandler( } if (range != null) { - editText.text.replace(range.start, range.endInclusive + 1, replacement) - editText.setSelection(range.start + replacement.length) + this.text.replace(range.start, range.endInclusive + 1, replacement) + this.setSelection(range.start + replacement.length) } else { - editText.text.append(replacement) - editText.setSelection(editText.text.length) + this.text.append(replacement) + this.setSelection(this.text.length) } } - fun autoComplete(item: ChatActivity.AutoCompletionState) { + fun autoComplete(item: AutoCompletionState) { val suffix = if (item.range.start == 0) ": " else " " val replacement = "${item.completion.name}$suffix" val previousReplacement = item.lastCompletion?.let { "${item.lastCompletion.name}$suffix" } if (previousReplacement != null && - editText.text.length >= item.range.start + previousReplacement.length && - editText.text.substring( + this.text.length >= item.range.start + previousReplacement.length && + this.text.substring( item.range.start, item.range.start + previousReplacement.length ) == previousReplacement) { - editText.text.replace( + this.text.replace( item.range.start, item.range.start + previousReplacement.length, replacement ) - editText.setSelection(item.range.start + replacement.length) + this.setSelection(item.range.start + replacement.length) } else { - editText.text.replace(item.range.start, item.range.endInclusive + 1, replacement) - editText.setSelection(item.range.start + replacement.length) + this.text.replace(item.range.start, item.range.endInclusive + 1, replacement) + this.setSelection(item.range.start + replacement.length) } } fun replace(text: CharSequence?) { - editText.setText(text) - editText.setSelection(editText.text.length) + this.setText(text) + this.setSelection(this.text.length) + } + + private fun selectedFormattingChanged(range: IntRange = selection) { + formattingListener?.invoke( + isBold(range), + isItalic(range), + isUnderline(range), + isStrikethrough(range), + isMonospace(range), + foregroundColor(range), + backgroundColor(range) + ) } } diff --git a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/RichToolbar.kt b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/RichToolbar.kt new file mode 100644 index 000000000..a952197a1 --- /dev/null +++ b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/input/RichToolbar.kt @@ -0,0 +1,108 @@ +package de.kuschku.quasseldroid.ui.chat.input + +import android.content.Context +import android.support.annotation.ColorInt +import android.support.v7.widget.Toolbar +import android.support.v7.widget.TooltipCompat +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import butterknife.BindView +import butterknife.ButterKnife +import de.kuschku.quasseldroid.R + +class RichToolbar : Toolbar { + @BindView(R.id.action_format_bold) + lateinit var boldButton: View + + @BindView(R.id.action_format_italic) + lateinit var italicButton: View + + @BindView(R.id.action_format_underline) + lateinit var underlineButton: View + + @BindView(R.id.action_format_strikethrough) + lateinit var strikethroughButton: View + + @BindView(R.id.action_format_monospace) + lateinit var monospaceButton: View + + @BindView(R.id.action_format_foreground) + lateinit var foregroundButton: View + + @BindView(R.id.action_format_foreground_preview) + lateinit var foregroundButtonPreview: View + + @BindView(R.id.action_format_background) + lateinit var backgroundButton: View + + @BindView(R.id.action_format_background_preview) + lateinit var backgroundButtonPreview: View + + @BindView(R.id.action_format_clear) + lateinit var clearButton: View + + private var listener: FormattingListener? = null + + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : + super(context, attrs, defStyleAttr) + + init { + LayoutInflater.from(context).inflate(R.layout.widget_formatting, this, true) + ButterKnife.bind(this) + + TooltipCompat.setTooltipText(boldButton, boldButton.contentDescription) + TooltipCompat.setTooltipText(italicButton, italicButton.contentDescription) + TooltipCompat.setTooltipText(underlineButton, underlineButton.contentDescription) + TooltipCompat.setTooltipText(strikethroughButton, strikethroughButton.contentDescription) + TooltipCompat.setTooltipText(monospaceButton, monospaceButton.contentDescription) + TooltipCompat.setTooltipText(foregroundButton, foregroundButton.contentDescription) + TooltipCompat.setTooltipText(backgroundButton, backgroundButton.contentDescription) + TooltipCompat.setTooltipText(clearButton, clearButton.contentDescription) + + boldButton.setOnClickListener { listener?.onBold() } + italicButton.setOnClickListener { listener?.onItalic() } + underlineButton.setOnClickListener { listener?.onUnderline() } + strikethroughButton.setOnClickListener { listener?.onStrikethrough() } + monospaceButton.setOnClickListener { listener?.onMonospace() } + foregroundButton.setOnClickListener { listener?.onForeground() } + backgroundButton.setOnClickListener { listener?.onBackground() } + clearButton.setOnClickListener { listener?.onClear() } + } + + fun setFormattingListener(listener: FormattingListener?) { + this.listener = listener + } + + fun update( + bold: Boolean = false, + italic: Boolean = false, + underline: Boolean = false, + strikethrough: Boolean = false, + monospace: Boolean = false, + @ColorInt foreground: Int, + @ColorInt background: Int + ) { + boldButton.isSelected = bold + italicButton.isSelected = italic + underlineButton.isSelected = underline + strikethroughButton.isSelected = strikethrough + monospaceButton.isSelected = monospace + + foregroundButtonPreview.setBackgroundColor(foreground) + backgroundButtonPreview.setBackgroundColor(background) + } + + interface FormattingListener { + fun onBold() = Unit + fun onItalic() = Unit + fun onUnderline() = Unit + fun onStrikethrough() = Unit + fun onMonospace() = Unit + fun onForeground() = Unit + fun onBackground() = Unit + fun onClear() = Unit + } +} diff --git a/app/src/main/java/de/kuschku/quasseldroid/util/helper/CharSequenceHelper.kt b/app/src/main/java/de/kuschku/quasseldroid/util/helper/CharSequenceHelper.kt index 9f8856f87..cefe3ead6 100644 --- a/app/src/main/java/de/kuschku/quasseldroid/util/helper/CharSequenceHelper.kt +++ b/app/src/main/java/de/kuschku/quasseldroid/util/helper/CharSequenceHelper.kt @@ -1,5 +1,133 @@ package de.kuschku.quasseldroid.util.helper +private class DelimitedRangesSequence( + private val input: CharSequence, + private val startIndex: Int, + private val limit: Int, + private val getNextMatch: CharSequence.(Int) -> Pair<Int, Int>? +) : Sequence<IntRange> { + override fun iterator(): Iterator<IntRange> = object : Iterator<IntRange> { + var nextState: Int = -1 // -1 for unknown, 0 for done, 1 for continue + var currentStartIndex: Int = startIndex.coerceIn(0, input.length) + var nextSearchIndex: Int = currentStartIndex + var nextItem: IntRange? = null + var counter: Int = 0 + + private fun calcNext() { + if (nextSearchIndex < 0) { + nextState = 0 + nextItem = null + } else { + if (limit > 0 && ++counter >= limit || nextSearchIndex > input.length) { + nextItem = currentStartIndex..input.lastIndex + nextSearchIndex = -1 + } else { + val match = input.getNextMatch(nextSearchIndex) + if (match == null) { + nextItem = currentStartIndex..input.lastIndex + nextSearchIndex = -1 + } else { + val (index, length) = match + nextItem = currentStartIndex until index + currentStartIndex = index + length + nextSearchIndex = currentStartIndex + if (length == 0) 1 else 0 + } + } + nextState = 1 + } + } + + override fun next(): IntRange { + if (nextState == -1) + calcNext() + if (nextState == 0) + throw NoSuchElementException() + val result = nextItem as IntRange + // Clean next to avoid keeping reference on yielded instance + nextItem = null + nextState = -1 + return result + } + + override fun hasNext(): Boolean { + if (nextState == -1) + calcNext() + return nextState == 1 + } + } +} + +internal fun CharSequence.regionMatchesImpl(thisOffset: Int, other: CharSequence, otherOffset: Int, + length: Int, ignoreCase: Boolean): Boolean { + if ((otherOffset < 0) || (thisOffset < 0) || (thisOffset > this.length - length) + || (otherOffset > other.length - length)) { + return false + } + + for (index in 0 until length) { + if (!this[thisOffset + index].equals(other[otherOffset + index], ignoreCase)) + return false + } + return true +} + +private fun CharSequence.findAnyOf(strings: Collection<String>, startIndex: Int, + ignoreCase: Boolean, last: Boolean): Pair<Int, String>? { + if (!ignoreCase && strings.size == 1) { + val string = strings.single() + val index = if (!last) indexOf(string, startIndex) else lastIndexOf(string, startIndex) + return if (index < 0) null else index to string + } + + val indices = if (!last) startIndex.coerceAtLeast(0)..length else startIndex.coerceAtMost( + lastIndex + ) downTo 0 + + if (this is String) { + for (index in indices) { + val matchingString = strings.firstOrNull { + it.regionMatches( + 0, this, index, it.length, ignoreCase + ) + } + if (matchingString != null) + return index to matchingString + } + } else { + for (index in indices) { + val matchingString = strings.firstOrNull { + it.regionMatchesImpl( + 0, this, index, it.length, ignoreCase + ) + } + if (matchingString != null) + return index to matchingString + } + } + + return null +} + +private fun CharSequence.rangesDelimitedBy(delimiters: Array<out String>, startIndex: Int = 0, + ignoreCase: Boolean = false, + limit: Int = 0): Sequence<IntRange> { + require(limit >= 0, { "Limit must be non-negative, but was $limit." }) + val delimitersList = delimiters.asList() + + return DelimitedRangesSequence( + this, startIndex, limit, { startIndex -> + findAnyOf( + delimitersList, startIndex, ignoreCase = ignoreCase, last = false + )?.let { it.first to it.second.length } + }) +} + +fun CharSequence.splitToSequence(vararg delimiters: String, ignoreCase: Boolean = false, + limit: Int = 0): Sequence<CharSequence> = + rangesDelimitedBy(delimiters, ignoreCase = ignoreCase, limit = limit).map { subSequence(it) } + +fun CharSequence.lineSequence(): Sequence<CharSequence> = splitToSequence("\r\n", "\n", "\r") + fun CharSequence.lastWord(cursor: Int = this.length, onlyBeforeCursor: Boolean = false): CharSequence { return lastWordIndices(cursor, onlyBeforeCursor)?.let { subSequence(it) } ?: "" @@ -28,4 +156,4 @@ fun CharSequence.lastWordIndices(cursor: Int = this.length, } else { null } -} \ No newline at end of file +} diff --git a/app/src/main/java/de/kuschku/quasseldroid/util/helper/EditableHelper.kt b/app/src/main/java/de/kuschku/quasseldroid/util/helper/EditableHelper.kt new file mode 100644 index 000000000..aa6fdbbc5 --- /dev/null +++ b/app/src/main/java/de/kuschku/quasseldroid/util/helper/EditableHelper.kt @@ -0,0 +1,34 @@ +package de.kuschku.quasseldroid.util.helper + +import android.text.Editable +import android.text.Spanned +import de.kuschku.quasseldroid.util.irc.format.spans.Copyable + +inline fun <reified U, T> Editable.removeSpans( + range: IntRange, removeInvalid: Boolean = false, f: (U) -> T? +) where T : Copyable<T> { + for (raw in getSpans<U>(range.start, range.endInclusive + 1, U::class.java)) { + val spanFlags = getSpanFlags(raw) + if (spanFlags and Spanned.SPAN_COMPOSING != 0) continue + + val spanEnd = getSpanEnd(raw) + val spanStart = getSpanStart(raw) + + val span = f(raw) + if (span == null) { + if (removeInvalid) + removeSpan(raw) + } else { + removeSpan(raw) + + for (spanRange in spanStart until spanEnd without range) { + setSpan( + span.copy(), + spanRange.start, + spanRange.endInclusive + 1, + (spanFlags and 0x03.inv()) or 0x01 + ) + } + } + } +} diff --git a/app/src/main/java/de/kuschku/quasseldroid/util/helper/SpannedHelper.kt b/app/src/main/java/de/kuschku/quasseldroid/util/helper/SpannedHelper.kt index cb1fdb818..fa613a52a 100644 --- a/app/src/main/java/de/kuschku/quasseldroid/util/helper/SpannedHelper.kt +++ b/app/src/main/java/de/kuschku/quasseldroid/util/helper/SpannedHelper.kt @@ -1,129 +1,33 @@ package de.kuschku.quasseldroid.util.helper -private class DelimitedRangesSequence( - private val input: CharSequence, - private val startIndex: Int, - private val limit: Int, - private val getNextMatch: CharSequence.(Int) -> Pair<Int, Int>? -) : Sequence<IntRange> { - override fun iterator(): Iterator<IntRange> = object : Iterator<IntRange> { - var nextState: Int = -1 // -1 for unknown, 0 for done, 1 for continue - var currentStartIndex: Int = startIndex.coerceIn(0, input.length) - var nextSearchIndex: Int = currentStartIndex - var nextItem: IntRange? = null - var counter: Int = 0 +import android.text.Spanned - private fun calcNext() { - if (nextSearchIndex < 0) { - nextState = 0 - nextItem = null - } else { - if (limit > 0 && ++counter >= limit || nextSearchIndex > input.length) { - nextItem = currentStartIndex..input.lastIndex - nextSearchIndex = -1 - } else { - val match = input.getNextMatch(nextSearchIndex) - if (match == null) { - nextItem = currentStartIndex..input.lastIndex - nextSearchIndex = -1 - } else { - val (index, length) = match - nextItem = currentStartIndex until index - currentStartIndex = index + length - nextSearchIndex = currentStartIndex + if (length == 0) 1 else 0 - } - } - nextState = 1 - } - } - - override fun next(): IntRange { - if (nextState == -1) - calcNext() - if (nextState == 0) - throw NoSuchElementException() - val result = nextItem as IntRange - // Clean next to avoid keeping reference on yielded instance - nextItem = null - nextState = -1 - return result - } - - override fun hasNext(): Boolean { - if (nextState == -1) - calcNext() - return nextState == 1 - } +inline fun <reified U> Spanned.spans(range: IntRange) = + getSpans(range.start, range.endInclusive + 1, U::class.java).filter { + getSpanFlags(it) and Spanned.SPAN_COMPOSING == 0 && + (getSpanEnd(it) != range.start || + getSpanFlags(it) and 0x02 != 0) } -} -internal fun CharSequence.regionMatchesImpl(thisOffset: Int, other: CharSequence, otherOffset: Int, - length: Int, ignoreCase: Boolean): Boolean { - if ((otherOffset < 0) || (thisOffset < 0) || (thisOffset > this.length - length) - || (otherOffset > other.length - length)) { - return false +inline fun <reified U> Spanned.spans(range: IntRange, f: (U) -> Boolean) = + getSpans(range.start, range.last + 1, U::class.java).filter { + f(it) && + getSpanFlags(it) and Spanned.SPAN_COMPOSING == 0 && + (getSpanEnd(it) != range.start || + getSpanFlags(it) and 0x02 != 0) } - for (index in 0 until length) { - if (!this[thisOffset + index].equals(other[otherOffset + index], ignoreCase)) - return false +inline fun <reified U> Spanned.hasSpans(range: IntRange) = + getSpans(range.start, range.endInclusive + 1, U::class.java).any { + getSpanFlags(it) and Spanned.SPAN_COMPOSING == 0 && + (getSpanEnd(it) != range.start || + getSpanFlags(it) and 0x02 != 0) } - return true -} - -private fun CharSequence.findAnyOf(strings: Collection<String>, startIndex: Int, - ignoreCase: Boolean, last: Boolean): Pair<Int, String>? { - if (!ignoreCase && strings.size == 1) { - val string = strings.single() - val index = if (!last) indexOf(string, startIndex) else lastIndexOf(string, startIndex) - return if (index < 0) null else index to string - } - - val indices = if (!last) startIndex.coerceAtLeast(0)..length else startIndex.coerceAtMost( - lastIndex - ) downTo 0 - if (this is String) { - for (index in indices) { - val matchingString = strings.firstOrNull { - it.regionMatches( - 0, this, index, it.length, ignoreCase - ) - } - if (matchingString != null) - return index to matchingString - } - } else { - for (index in indices) { - val matchingString = strings.firstOrNull { - it.regionMatchesImpl( - 0, this, index, it.length, ignoreCase - ) - } - if (matchingString != null) - return index to matchingString - } +inline fun <reified U> Spanned.hasSpans(range: IntRange, f: (U) -> Boolean) = + getSpans(range.start, range.last + 1, U::class.java).any { + f(it) && + getSpanFlags(it) and Spanned.SPAN_COMPOSING == 0 && + (getSpanEnd(it) != range.start || + getSpanFlags(it) and 0x02 != 0) } - - return null -} - -private fun CharSequence.rangesDelimitedBy(delimiters: Array<out String>, startIndex: Int = 0, - ignoreCase: Boolean = false, - limit: Int = 0): Sequence<IntRange> { - require(limit >= 0, { "Limit must be non-negative, but was $limit." }) - val delimitersList = delimiters.asList() - - return DelimitedRangesSequence( - this, startIndex, limit, { startIndex -> - findAnyOf( - delimitersList, startIndex, ignoreCase = ignoreCase, last = false - )?.let { it.first to it.second.length } - }) -} - -fun CharSequence.splitToSequence(vararg delimiters: String, ignoreCase: Boolean = false, - limit: Int = 0): Sequence<CharSequence> = - rangesDelimitedBy(delimiters, ignoreCase = ignoreCase, limit = limit).map { subSequence(it) } - -fun CharSequence.lineSequence(): Sequence<CharSequence> = splitToSequence("\r\n", "\n", "\r") \ No newline at end of file diff --git a/app/src/main/java/de/kuschku/quasseldroid/util/ui/DoubleClickHelper.kt b/app/src/main/java/de/kuschku/quasseldroid/util/ui/DoubleClickHelper.kt new file mode 100644 index 000000000..917f84c55 --- /dev/null +++ b/app/src/main/java/de/kuschku/quasseldroid/util/ui/DoubleClickHelper.kt @@ -0,0 +1,24 @@ +package de.kuschku.quasseldroid.util.ui + +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import android.widget.EditText + +class DoubleClickHelper(editText: EditText) : View.OnTouchListener { + var doubleClickListener: (() -> Unit)? = null + + private val gestureDetector = GestureDetector( + editText.context, object : GestureDetector.SimpleOnGestureListener() { + override fun onDoubleTap(e: MotionEvent?): Boolean { + doubleClickListener?.invoke() + return true + } + + override fun onDoubleTapEvent(e: MotionEvent?): Boolean { + return true + } + }) + + override fun onTouch(v: View?, event: MotionEvent?) = gestureDetector.onTouchEvent(event) +} diff --git a/app/src/main/java/de/kuschku/quasseldroid/util/ui/EditTextSelectionChange.kt b/app/src/main/java/de/kuschku/quasseldroid/util/ui/EditTextSelectionChange.kt index ad316e498..aff74edc5 100644 --- a/app/src/main/java/de/kuschku/quasseldroid/util/ui/EditTextSelectionChange.kt +++ b/app/src/main/java/de/kuschku/quasseldroid/util/ui/EditTextSelectionChange.kt @@ -4,7 +4,7 @@ import android.content.Context import android.support.v7.widget.AppCompatEditText import android.util.AttributeSet -class EditTextSelectionChange : AppCompatEditText { +open class EditTextSelectionChange : AppCompatEditText { constructor(context: Context?) : super(context) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : @@ -24,4 +24,4 @@ class EditTextSelectionChange : AppCompatEditText { super.onSelectionChanged(selStart, selEnd) selectionChangeListener?.invoke(selStart until selEnd) } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout-land/layout_main.xml b/app/src/main/res/layout-land/layout_main.xml index 3920f6034..9c4f28b83 100644 --- a/app/src/main/res/layout-land/layout_main.xml +++ b/app/src/main/res/layout-land/layout_main.xml @@ -34,6 +34,11 @@ android:background="?colorBackgroundCard" /> </LinearLayout> - <include layout="@layout/layout_slider" /> + <fragment + android:id="@+id/fragment_chatline" + android:name="de.kuschku.quasseldroid.ui.chat.input.ChatlineFragment" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:layout="@layout/fragment_chatline" /> -</com.sothree.slidinguppanel.SlidingUpPanelLayout> \ No newline at end of file +</com.sothree.slidinguppanel.SlidingUpPanelLayout> diff --git a/app/src/main/res/layout-sw600dp-land/layout_main.xml b/app/src/main/res/layout-sw600dp-land/layout_main.xml index 736c78827..c88d225cd 100644 --- a/app/src/main/res/layout-sw600dp-land/layout_main.xml +++ b/app/src/main/res/layout-sw600dp-land/layout_main.xml @@ -40,8 +40,13 @@ </LinearLayout> - <include layout="@layout/layout_slider" /> + <fragment + android:id="@+id/fragment_chatline" + android:name="de.kuschku.quasseldroid.ui.chat.input.ChatlineFragment" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:layout="@layout/fragment_chatline" /> </com.sothree.slidinguppanel.SlidingUpPanelLayout> -</LinearLayout> \ No newline at end of file +</LinearLayout> diff --git a/app/src/main/res/layout/layout_slider.xml b/app/src/main/res/layout/fragment_chatline.xml similarity index 100% rename from app/src/main/res/layout/layout_slider.xml rename to app/src/main/res/layout/fragment_chatline.xml diff --git a/app/src/main/res/layout/layout_editor.xml b/app/src/main/res/layout/layout_editor.xml index 87b97cf1f..65fc344c9 100644 --- a/app/src/main/res/layout/layout_editor.xml +++ b/app/src/main/res/layout/layout_editor.xml @@ -27,7 +27,7 @@ app:layout_constraintStart_toEndOf="@+id/tab_complete" app:layout_constraintTop_toTopOf="parent"> - <de.kuschku.quasseldroid.util.ui.EditTextSelectionChange + <de.kuschku.quasseldroid.ui.chat.input.RichEditText android:id="@+id/chatline" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -71,18 +71,10 @@ android:background="?attr/colorBackgroundCard" app:layout_constraintBottom_toBottomOf="parent"> - <android.support.v7.widget.Toolbar + <de.kuschku.quasseldroid.ui.chat.input.RichToolbar android:id="@+id/formatting_toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" - app:contentInsetStart="0dip"> - - <HorizontalScrollView - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <include layout="@layout/widget_formatting" /> - </HorizontalScrollView> - </android.support.v7.widget.Toolbar> + app:contentInsetStart="0dip" /> </android.support.design.widget.AppBarLayout> -</android.support.constraint.ConstraintLayout> \ No newline at end of file +</android.support.constraint.ConstraintLayout> diff --git a/app/src/main/res/layout/layout_main.xml b/app/src/main/res/layout/layout_main.xml index 52a728255..b57db18f8 100644 --- a/app/src/main/res/layout/layout_main.xml +++ b/app/src/main/res/layout/layout_main.xml @@ -39,8 +39,13 @@ android:background="?colorBackgroundCard" /> </LinearLayout> - <include layout="@layout/layout_slider" /> + <fragment + android:id="@+id/fragment_chatline" + android:name="de.kuschku.quasseldroid.ui.chat.input.ChatlineFragment" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:layout="@layout/fragment_chatline" /> </com.sothree.slidinguppanel.SlidingUpPanelLayout> -</LinearLayout> \ No newline at end of file +</LinearLayout> diff --git a/app/src/main/res/layout/widget_formatting.xml b/app/src/main/res/layout/widget_formatting.xml index eebeda3c6..c333c4185 100644 --- a/app/src/main/res/layout/widget_formatting.xml +++ b/app/src/main/res/layout/widget_formatting.xml @@ -1,127 +1,130 @@ <?xml version="1.0" encoding="utf-8"?> -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" +<HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="wrap_content" - android:layout_height="match_parent" - android:padding="2dp"> - - <android.support.v7.widget.AppCompatImageButton - android:id="@+id/action_format_bold" - style="@style/Widget.Button.Format" - android:contentDescription="@string/label_bold" - app:srcCompat="@drawable/ic_format_bold" - app:tint="?colorControlNormal" /> - - <Space - android:layout_width="2dp" - android:layout_height="match_parent" /> - - <android.support.v7.widget.AppCompatImageButton - android:id="@+id/action_format_italic" - style="@style/Widget.Button.Format" - android:contentDescription="@string/label_italic" - app:srcCompat="@drawable/ic_format_italic" - app:tint="?colorControlNormal" /> - - <Space - android:layout_width="2dp" - android:layout_height="match_parent" /> - - <android.support.v7.widget.AppCompatImageButton - android:id="@+id/action_format_underline" - style="@style/Widget.Button.Format" - android:contentDescription="@string/label_underline" - app:srcCompat="@drawable/ic_format_underline" - app:tint="?colorControlNormal" /> - - <Space - android:layout_width="2dp" - android:layout_height="match_parent" /> - - <android.support.v7.widget.AppCompatImageButton - android:id="@+id/action_format_strikethrough" - style="@style/Widget.Button.Format" - android:contentDescription="@string/label_strikethrough" - app:srcCompat="@drawable/ic_format_strikethrough" - app:tint="?colorControlNormal" /> - - <Space - android:layout_width="2dp" - android:layout_height="match_parent" /> - - <android.support.v7.widget.AppCompatImageButton - android:id="@+id/action_format_monospace" - style="@style/Widget.Button.Format" - android:contentDescription="@string/label_bold" - app:srcCompat="@drawable/ic_format_monospace" - app:tint="?colorControlNormal" /> - - <Space - android:layout_width="2dp" - android:layout_height="match_parent" /> - - <FrameLayout - android:layout_width="48dp" - android:layout_height="48dp" - android:layout_gravity="center"> + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:padding="2dp"> <android.support.v7.widget.AppCompatImageButton - android:id="@+id/action_format_foreground" + android:id="@+id/action_format_bold" style="@style/Widget.Button.Format" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:contentDescription="@string/label_foreground" - android:paddingBottom="4dp" - app:srcCompat="@drawable/ic_format_foreground" + android:contentDescription="@string/label_bold" + app:srcCompat="@drawable/ic_format_bold" app:tint="?colorControlNormal" /> - <View - android:id="@+id/action_format_foreground_preview" - android:layout_width="match_parent" - android:layout_height="4dp" - android:layout_gravity="center_horizontal|bottom" - android:layout_margin="8dp" - android:background="?colorForeground" /> + <Space + android:layout_width="2dp" + android:layout_height="match_parent" /> - </FrameLayout> + <android.support.v7.widget.AppCompatImageButton + android:id="@+id/action_format_italic" + style="@style/Widget.Button.Format" + android:contentDescription="@string/label_italic" + app:srcCompat="@drawable/ic_format_italic" + app:tint="?colorControlNormal" /> - <Space - android:layout_width="2dp" - android:layout_height="match_parent" /> + <Space + android:layout_width="2dp" + android:layout_height="match_parent" /> - <FrameLayout - android:layout_width="48dp" - android:layout_height="48dp" - android:layout_gravity="center"> + <android.support.v7.widget.AppCompatImageButton + android:id="@+id/action_format_underline" + style="@style/Widget.Button.Format" + android:contentDescription="@string/label_underline" + app:srcCompat="@drawable/ic_format_underline" + app:tint="?colorControlNormal" /> + + <Space + android:layout_width="2dp" + android:layout_height="match_parent" /> <android.support.v7.widget.AppCompatImageButton - android:id="@+id/action_format_background" + android:id="@+id/action_format_strikethrough" style="@style/Widget.Button.Format" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:contentDescription="@string/label_background" - android:paddingBottom="4dp" - app:srcCompat="@drawable/ic_format_background" + android:contentDescription="@string/label_strikethrough" + app:srcCompat="@drawable/ic_format_strikethrough" app:tint="?colorControlNormal" /> - <View - android:id="@+id/action_format_background_preview" - android:layout_width="match_parent" - android:layout_height="4dp" - android:layout_gravity="center_horizontal|bottom" - android:layout_margin="8dp" - android:background="?colorBackground" /> - - </FrameLayout> - - <Space - android:layout_width="2dp" - android:layout_height="match_parent" /> - - <android.support.v7.widget.AppCompatImageButton - android:id="@+id/action_format_clear" - style="@style/Widget.Button.Format" - android:contentDescription="@string/label_clear_formatting" - app:srcCompat="@drawable/ic_format_clear" - app:tint="?colorControlNormal" /> -</LinearLayout> \ No newline at end of file + <Space + android:layout_width="2dp" + android:layout_height="match_parent" /> + + <android.support.v7.widget.AppCompatImageButton + android:id="@+id/action_format_monospace" + style="@style/Widget.Button.Format" + android:contentDescription="@string/label_bold" + app:srcCompat="@drawable/ic_format_monospace" + app:tint="?colorControlNormal" /> + + <Space + android:layout_width="2dp" + android:layout_height="match_parent" /> + + <FrameLayout + android:layout_width="48dp" + android:layout_height="48dp" + android:layout_gravity="center"> + + <android.support.v7.widget.AppCompatImageButton + android:id="@+id/action_format_foreground" + style="@style/Widget.Button.Format" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:contentDescription="@string/label_foreground" + android:paddingBottom="4dp" + app:srcCompat="@drawable/ic_format_foreground" + app:tint="?colorControlNormal" /> + + <View + android:id="@+id/action_format_foreground_preview" + android:layout_width="match_parent" + android:layout_height="4dp" + android:layout_gravity="center_horizontal|bottom" + android:layout_margin="8dp" + android:background="?colorForeground" /> + </FrameLayout> + + <Space + android:layout_width="2dp" + android:layout_height="match_parent" /> + + <FrameLayout + android:layout_width="48dp" + android:layout_height="48dp" + android:layout_gravity="center"> + + <android.support.v7.widget.AppCompatImageButton + android:id="@+id/action_format_background" + style="@style/Widget.Button.Format" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:contentDescription="@string/label_background" + android:paddingBottom="4dp" + app:srcCompat="@drawable/ic_format_background" + app:tint="?colorControlNormal" /> + + <View + android:id="@+id/action_format_background_preview" + android:layout_width="match_parent" + android:layout_height="4dp" + android:layout_gravity="center_horizontal|bottom" + android:layout_margin="8dp" + android:background="?colorBackground" /> + </FrameLayout> + + <Space + android:layout_width="2dp" + android:layout_height="match_parent" /> + + <android.support.v7.widget.AppCompatImageButton + android:id="@+id/action_format_clear" + style="@style/Widget.Button.Format" + android:contentDescription="@string/label_clear_formatting" + app:srcCompat="@drawable/ic_format_clear" + app:tint="?colorControlNormal" /> + </LinearLayout> +</HorizontalScrollView> diff --git a/viewmodel/src/main/java/de/kuschku/quasseldroid/viewmodel/EditorViewModel.kt b/viewmodel/src/main/java/de/kuschku/quasseldroid/viewmodel/EditorViewModel.kt new file mode 100644 index 000000000..b785ae2d9 --- /dev/null +++ b/viewmodel/src/main/java/de/kuschku/quasseldroid/viewmodel/EditorViewModel.kt @@ -0,0 +1,125 @@ +package de.kuschku.quasseldroid.viewmodel + +import android.arch.lifecycle.ViewModel +import de.kuschku.libquassel.protocol.Buffer_Type +import de.kuschku.libquassel.quassel.syncables.IrcChannel +import de.kuschku.libquassel.quassel.syncables.IrcUser +import de.kuschku.libquassel.session.ISession +import de.kuschku.libquassel.util.Optional +import de.kuschku.libquassel.util.flag.hasFlag +import de.kuschku.quasseldroid.util.helper.combineLatest +import de.kuschku.quasseldroid.viewmodel.data.AutoCompleteItem +import de.kuschku.quasseldroid.viewmodel.data.BufferStatus +import io.reactivex.Observable +import io.reactivex.subjects.BehaviorSubject +import java.util.concurrent.TimeUnit + +class EditorViewModel : ViewModel() { + val quasselViewModel = BehaviorSubject.create<QuasselViewModel>() + + val session = quasselViewModel.switchMap(QuasselViewModel::session) + val buffer = quasselViewModel.switchMap(QuasselViewModel::buffer) + + val lastWord = BehaviorSubject.create<Observable<Pair<String, IntRange>>>() + + val rawAutoCompleteData: Observable<Triple<Optional<ISession>, Int, Pair<String, IntRange>>> = + combineLatest(session, buffer, lastWord).switchMap { (sessionOptional, id, lastWordWrapper) -> + lastWordWrapper + .distinctUntilChanged() + .map { lastWord -> + Triple(sessionOptional, id, lastWord) + } + } + + val autoCompleteData = rawAutoCompleteData + .distinctUntilChanged() + .debounce(300, TimeUnit.MILLISECONDS) + .switchMap { (sessionOptional, id, lastWord) -> + val session = sessionOptional.orNull() + val bufferSyncer = session?.bufferSyncer + val bufferInfo = bufferSyncer?.bufferInfo(id) + if (bufferSyncer != null) { + session.liveNetworks().switchMap { networks -> + bufferSyncer.liveBufferInfos().switchMap { infos -> + if (bufferInfo?.type?.hasFlag(Buffer_Type.ChannelBuffer) == true) { + val network = networks[bufferInfo.networkId] + val ircChannel = network?.ircChannel( + bufferInfo.bufferName + ) + if (ircChannel != null) { + ircChannel.liveIrcUsers().switchMap { users -> + val buffers: List<Observable<AutoCompleteItem.ChannelItem>?> = infos.values + .filter { + it.type.toInt() == Buffer_Type.ChannelBuffer.toInt() + }.mapNotNull { info -> + networks[info.networkId]?.let { info to it } + }.map { (info, network) -> + network.liveIrcChannel( + info.bufferName + ).switchMap { channel -> + channel.liveUpdates().map { + AutoCompleteItem.ChannelItem( + info = info, + network = network.networkInfo(), + bufferStatus = when (it) { + IrcChannel.NULL -> BufferStatus.OFFLINE + else -> BufferStatus.ONLINE + }, + description = it.topic() + ) + } + } + } + val nicks = users.map<IrcUser, Observable<AutoCompleteItem.UserItem>?> { + it.updates().map { user -> + val userModes = ircChannel.userModes(user) + val prefixModes = network.prefixModes() + + val lowestMode = userModes.mapNotNull(prefixModes::indexOf).min() + ?: prefixModes.size + + AutoCompleteItem.UserItem( + user.nick(), + network.modesToPrefixes(userModes), + lowestMode, + user.realName(), + user.isAway(), + network.support("CASEMAPPING"), + Regex("[us]id(\\d+)").matchEntire(user.user())?.groupValues?.lastOrNull()?.let { + "https://www.irccloud.com/avatar-redirect/$it" + } + ) + } + } + + combineLatest<AutoCompleteItem>(nicks + buffers) + .map { list -> + val ignoredStartingCharacters = charArrayOf( + '-', '_', '[', ']', '{', '}', '|', '`', '^', '.', '\\', '@' + ) + + Pair( + lastWord.first, + list.filter { + it.name.trimStart(*ignoredStartingCharacters) + .startsWith( + lastWord.first.trimStart(*ignoredStartingCharacters), + ignoreCase = true + ) + }.sorted() + ) + } + } + } else { + Observable.just(Pair(lastWord.first, emptyList())) + } + } else { + Observable.just(Pair(lastWord.first, emptyList())) + } + } + } + } else { + Observable.just(Pair(lastWord.first, emptyList())) + } + } +} diff --git a/viewmodel/src/main/java/de/kuschku/quasseldroid/viewmodel/QuasselViewModel.kt b/viewmodel/src/main/java/de/kuschku/quasseldroid/viewmodel/QuasselViewModel.kt index c87652c40..53d8762cb 100644 --- a/viewmodel/src/main/java/de/kuschku/quasseldroid/viewmodel/QuasselViewModel.kt +++ b/viewmodel/src/main/java/de/kuschku/quasseldroid/viewmodel/QuasselViewModel.kt @@ -212,111 +212,6 @@ class QuasselViewModel : ViewModel() { } } - val lastWord = BehaviorSubject.create<Observable<Pair<String, IntRange>>>() - - val rawAutoCompleteData: Observable<Triple<Optional<ISession>, Int, Pair<String, IntRange>>> = - combineLatest(session, buffer, lastWord).switchMap { (sessionOptional, id, lastWordWrapper) -> - lastWordWrapper - .distinctUntilChanged() - .map { lastWord -> - Triple(sessionOptional, id, lastWord) - } - } - - var time = 0L - var previous: Any? = null - val autoCompleteData = rawAutoCompleteData - .distinctUntilChanged() - .debounce(300, TimeUnit.MILLISECONDS) - .switchMap { (sessionOptional, id, lastWord) -> - val session = sessionOptional.orNull() - val bufferSyncer = session?.bufferSyncer - val bufferInfo = bufferSyncer?.bufferInfo(id) - if (bufferSyncer != null) { - session.liveNetworks().switchMap { networks -> - bufferSyncer.liveBufferInfos().switchMap { infos -> - if (bufferInfo?.type?.hasFlag(Buffer_Type.ChannelBuffer) == true) { - val network = networks[bufferInfo.networkId] - val ircChannel = network?.ircChannel( - bufferInfo.bufferName - ) - if (ircChannel != null) { - ircChannel.liveIrcUsers().switchMap { users -> - val buffers: List<Observable<AutoCompleteItem.ChannelItem>?> = infos.values - .filter { - it.type.toInt() == Buffer_Type.ChannelBuffer.toInt() - }.mapNotNull { info -> - networks[info.networkId]?.let { info to it } - }.map { (info, network) -> - network.liveIrcChannel( - info.bufferName - ).switchMap { channel -> - channel.liveUpdates().map { - AutoCompleteItem.ChannelItem( - info = info, - network = network.networkInfo(), - bufferStatus = when (it) { - IrcChannel.NULL -> BufferStatus.OFFLINE - else -> BufferStatus.ONLINE - }, - description = it.topic() - ) - } - } - } - val nicks = users.map<IrcUser, Observable<AutoCompleteItem.UserItem>?> { - it.updates().map { user -> - val userModes = ircChannel.userModes(user) - val prefixModes = network.prefixModes() - - val lowestMode = userModes.mapNotNull(prefixModes::indexOf).min() - ?: prefixModes.size - - AutoCompleteItem.UserItem( - user.nick(), - network.modesToPrefixes(userModes), - lowestMode, - user.realName(), - user.isAway(), - network.support("CASEMAPPING"), - Regex("[us]id(\\d+)").matchEntire(user.user())?.groupValues?.lastOrNull()?.let { - "https://www.irccloud.com/avatar-redirect/$it" - } - ) - } - } - - combineLatest<AutoCompleteItem>(nicks + buffers) - .map { list -> - val ignoredStartingCharacters = charArrayOf( - '-', '_', '[', ']', '{', '}', '|', '`', '^', '.', '\\', '@' - ) - - Pair( - lastWord.first, - list.filter { - it.name.trimStart(*ignoredStartingCharacters) - .startsWith( - lastWord.first.trimStart(*ignoredStartingCharacters), - ignoreCase = true - ) - }.sorted() - ) - } - } - } else { - Observable.just(Pair(lastWord.first, emptyList())) - } - } else { - Observable.just(Pair(lastWord.first, emptyList())) - } - } - } - } else { - Observable.just(Pair(lastWord.first, emptyList())) - } - } - val bufferViewConfigs = bufferViewManager.mapSwitchMap { manager -> manager.liveBufferViewConfigs().map { ids -> ids.mapNotNull { id -> -- GitLab